feat(user-apps): appearance settings (dark mode + reduced motion), product morph, filter sheet
Applies to both user-app and user-app-v2. Logic and structure are shared; each app keeps its own look. Appearance settings - New "تنظیمات ظاهری" page (/settings/appearance), opened from the dashboard and available without login. It offers a light/dark/system theme and a "fast & light" motion switch. - appearanceStore saves the choice to localStorage. A small inline boot script in index.html applies it before first paint so there is no white flash. The "system" option follows OS changes live. - New ToggleSwitch component. Dark mode - Theme LESS tokens now compile to CSS custom properties. Light/dark values come from a generated virtual:palette.css (themeConfig/ appearance.ts, with colors.json + new colors.dark.json). - New LESS alpha() plugin replaces fade() on theme tokens. - New semantic tokens: page, onInk, heroSurface, hairlineStrong, photoMat, photoStage. - The dark palette is layered (page < card < panel < border) so surfaces separate without shadows. - Hard-coded colors were replaced by tokens according to their role. - antd inputs/text get dark overrides; vant uses its own .van-theme-dark. - Light mode looks the same as before. Reduced motion - With html.motion-reduced, transitions are capped at 120ms, keyframe loops stop, and backdrop blurs and permanent will-change are dropped. - Spinners and the toast progress bar (which closes the toast) are exempt via .keep-motion. - Carousel autoplay, the v1 Lottie and the product morph honor the setting. Product detail - Opening a product is now a container-transform morph from the tapped card (ProductMorph). It replaces the bottom Drawer and uses composited transform/opacity only. - The detail view is seeded with the card's data, so it no longer opens blank and jumps. Filters and sheets - Filters are now a full-width bottom sheet. Selections stay a draft until applied, so the result list no longer re-renders while dragging the slider. - DropDown collapse animates grid-template-rows instead of a capped max-height. - v2 IslandSheet is restyled to the v2 design language (API unchanged). Fixes - filtersStore.reset() shallow-copied shared arrays, so clearing the filters never cleared the selected brands.
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
|
||||
<template #indicator>
|
||||
<img v-if="!indicator" src="/img/montzar-logo.png" alt="Montzar" class="spinner-brand" width="48" height="48" />
|
||||
<div class="loading" v-else>
|
||||
<!-- keep-motion: a working spinner, exempt from reduced motion. -->
|
||||
<div class="loading keep-motion" v-else>
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
|
||||
@@ -84,6 +84,14 @@ const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
// Dark mode: product photos are shot on white, so they sit on a light mat
|
||||
// instead of showing as white boxes on a dark card (@photoMat — transparent,
|
||||
// i.e. no change, in light mode).
|
||||
html.theme-dark .cats__mark img {
|
||||
background: @photoMat;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cats__title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useAppearanceStore } from '../../stores/appearanceStore'
|
||||
|
||||
export interface HeroSlide {
|
||||
key: string
|
||||
@@ -77,6 +78,7 @@ let startX = 0
|
||||
let pointerId: number | null = null
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
const appearance = useAppearanceStore()
|
||||
|
||||
const SWIPE_THRESHOLD = 46
|
||||
|
||||
@@ -183,7 +185,9 @@ function stop() {
|
||||
}
|
||||
function restart() {
|
||||
stop()
|
||||
if (props.autoplay && count.value > 1) {
|
||||
// No autoplay in reduced-motion mode: a slide changing on its own every
|
||||
// six seconds is exactly the background motion that mode turns off.
|
||||
if (props.autoplay && !appearance.isReducedMotion && count.value > 1) {
|
||||
timer = setInterval(next, 6000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
number.
|
||||
-->
|
||||
<template>
|
||||
<article class="panel" @click="emit('open', value)">
|
||||
<article class="panel" data-product-origin @click="emit('open', value)">
|
||||
<div class="panel__art">
|
||||
<img v-lazy="image" :alt="title" />
|
||||
</div>
|
||||
@@ -72,6 +72,14 @@ function format(v: number) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Dark mode: product photos are shot on white, so they sit on a light mat
|
||||
// instead of showing as white boxes on a dark card (@photoMat — transparent,
|
||||
// i.e. no change, in light mode).
|
||||
html.theme-dark .panel__art img {
|
||||
background: @photoMat;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.panel__title {
|
||||
margin: 14px 0 0;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -41,7 +41,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
background: @heroSurface;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px -18px rgba(20, 22, 26, 0.9);
|
||||
}
|
||||
@@ -61,8 +61,8 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
|
||||
padding: 6px 12px 6px 10px;
|
||||
border-radius: 999px;
|
||||
box-shadow: none;
|
||||
background: fade(@primary, 6%);
|
||||
border: 1px solid fade(@primary, 10%);
|
||||
background: alpha(@primary, 6%);
|
||||
border: 1px solid alpha(@primary, 10%);
|
||||
color: @primary;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
// Softer than the number, so the eye lands on the rate first.
|
||||
color: fade(@primary, 62%);
|
||||
color: alpha(@primary, 62%);
|
||||
}
|
||||
|
||||
.rate-strip--compact .rate-strip__num {
|
||||
@@ -95,7 +95,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split
|
||||
|
||||
.rate-strip--compact .rate-strip__value--muted {
|
||||
font-size: 11px;
|
||||
color: fade(@primary, 45%);
|
||||
color: alpha(@primary, 45%);
|
||||
}
|
||||
|
||||
.rate-strip--compact .rate-strip__unit,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
order time, not stored on the product — printing one here would be invented.
|
||||
-->
|
||||
<template>
|
||||
<article class="card" :class="{ 'card--wide': wide }" @click="emit('open', value)">
|
||||
<article class="card" :class="{ 'card--wide': wide }" data-product-origin @click="emit('open', value)">
|
||||
<div class="card__art">
|
||||
<img v-lazy="image" :alt="title" />
|
||||
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)">
|
||||
|
||||
@@ -136,6 +136,14 @@ const wageLabel = computed(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Dark mode: product photos are shot on white, so they sit on a light mat
|
||||
// instead of showing as white boxes on a dark card (@photoMat — transparent,
|
||||
// i.e. no change, in light mode).
|
||||
html.theme-dark .ci__art img {
|
||||
background: @photoMat;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.ci__artEmpty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -86,6 +86,14 @@ onMounted(async () => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Dark mode: product photos are shot on white, so they sit on a light mat
|
||||
// instead of showing as white boxes on a dark card (@photoMat — transparent,
|
||||
// i.e. no change, in light mode).
|
||||
html.theme-dark .oi__art img {
|
||||
background: @photoMat;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.oi__artEmpty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
layout, minus an invented number.
|
||||
-->
|
||||
<template>
|
||||
<article class="pc" @click="open">
|
||||
<article ref="root" class="pc" data-product-origin @click="open">
|
||||
<div class="pc__art">
|
||||
<img v-if="image" v-lazy="image" :alt="title" />
|
||||
<div v-else class="pc__artEmpty" aria-hidden="true">
|
||||
@@ -65,25 +65,13 @@
|
||||
<span class="pc__wage">{{ wageLabel(wage) }}٪ اجرت</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Drawer
|
||||
v-model:visible="drawer"
|
||||
placement="bottom"
|
||||
:closable="false"
|
||||
class="rtl"
|
||||
height="100vh"
|
||||
:bodyStyle="{ padding: 0, position: 'relative' }"
|
||||
>
|
||||
<SingleProduct v-if="drawer" :id="id" :key="id" @closeDrawer="drawer = false" />
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import SingleProduct from './SingleProduct.vue'
|
||||
import { useProduct } from '../../composables/useProduct'
|
||||
import { openProduct } from '../../composables/useProductMorph'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import type { ProductSummary } from '../../api/client'
|
||||
|
||||
@@ -95,9 +83,10 @@ const props = defineProps<{ value: ProductSummary; wageBefore?: number | null }>
|
||||
const favorites = useFavoritesStore()
|
||||
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
|
||||
|
||||
const drawer = ref(false)
|
||||
// The detail opens as a morph out of this card — see ProductMorph.vue.
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
function open() {
|
||||
drawer.value = true
|
||||
openProduct(props.value, root.value)
|
||||
}
|
||||
|
||||
// Whole percents print bare (۱۰٪), fractional ones keep two places (۱۰.۵٪) —
|
||||
@@ -143,6 +132,14 @@ function wageLabel(v: number) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Dark mode: product photos are shot on white, so they sit on a light mat
|
||||
// instead of showing as white boxes on a dark card (@photoMat — transparent,
|
||||
// i.e. no change, in light mode).
|
||||
html.theme-dark .pc__art img {
|
||||
background: @photoMat;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pc__artEmpty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -165,7 +162,7 @@ function wageLabel(v: number) {
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: @inkFaint;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
background: alpha(@canvas, 75%);
|
||||
backdrop-filter: blur(6px);
|
||||
transition: color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
@@ -185,7 +182,7 @@ function wageLabel(v: number) {
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
<!--
|
||||
The product detail overlay, opened as a container transform out of the card
|
||||
that was tapped — the replacement for the full-height bottom Drawer, which
|
||||
slid a blank sheet up from off-screen and then jumped when the detail fetch
|
||||
landed and the page finally rendered.
|
||||
|
||||
Three layers move, and only one of them is ever repainted:
|
||||
- the SHAPE: a plain fill in the card's colour, revealed by animating
|
||||
`clip-path: inset(... round r)` from the card's exact rect and corner
|
||||
radius out to the whole screen. It holds no content, so repainting it
|
||||
per frame costs next to nothing.
|
||||
- the PAGE (SingleProduct, mounted fully formed — it is seeded with the
|
||||
card's own ProductSummary, so there is nothing to wait for): it grows out
|
||||
of the card's centre and fades in, by transform and opacity only, so the
|
||||
compositor moves it without re-rasterising it.
|
||||
- the GHOST: one fixed <img> of the card's photo, flown by transform from
|
||||
where the card drew it to where the gallery draws it.
|
||||
An earlier version clipped the page itself; that forced a full-screen repaint
|
||||
of the whole page (photos, blurred buttons and all) on every frame and lagged
|
||||
on phones. Backdrop blurs inside the page are also switched off while
|
||||
anything is moving, for the same reason, and the scroll lock and the page's
|
||||
background refresh both wait until the motion is over.
|
||||
|
||||
Closing plays the morph back into the card if it is still on screen, and
|
||||
falls back to a short drop-and-fade if it is not (the list was scrolled or
|
||||
re-rendered). `prefers-reduced-motion` gets a plain crossfade.
|
||||
|
||||
Mounted once, in App.vue. Opened through composables/useProductMorph.ts.
|
||||
-->
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="current" class="pm" :class="{ 'is-moving': moving, 'is-closing': closing }" dir="rtl">
|
||||
<div ref="veil" class="pm__veil" aria-hidden="true"></div>
|
||||
<div ref="shape" class="pm__shape" aria-hidden="true"></div>
|
||||
|
||||
<div ref="sheet" class="pm__sheet no-scrollbar">
|
||||
<SingleProduct
|
||||
:id="current.product.id"
|
||||
:key="current.product.id"
|
||||
:initial="current.product"
|
||||
@closeDrawer="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<img v-if="ghostSrc" ref="ghost" class="pm__ghost" :src="ghostSrc" alt="" aria-hidden="true" decoding="sync" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import SingleProduct from './SingleProduct.vue'
|
||||
import { productMorph, type ProductMorphRequest } from '../../composables/useProductMorph'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const current = shallowRef<ProductMorphRequest | null>(null)
|
||||
const ghostSrc = ref<string | null>(null)
|
||||
const moving = ref(false)
|
||||
const closing = ref(false)
|
||||
|
||||
const veil = ref<HTMLElement | null>(null)
|
||||
const shape = ref<HTMLElement | null>(null)
|
||||
const sheet = ref<HTMLElement | null>(null)
|
||||
const ghost = ref<HTMLImageElement | null>(null)
|
||||
|
||||
// ── Timing ────────────────────────────────────────────────────
|
||||
// Opening rides a lightly-damped spring (≈1% overshoot): quick off the mark —
|
||||
// most of the travel is done in the first ~40% — with a short, soft settle.
|
||||
// Expressed as a CSS `linear()` easing so it still runs on the compositor like
|
||||
// any other easing; browsers without `linear()` get an ease-out-quint, which
|
||||
// reads almost the same minus the settle. Closing is shorter and never
|
||||
// overshoots — going back should feel like letting go, not a second entrance.
|
||||
const OPEN_MS = 420
|
||||
const CLOSE_MS = 300
|
||||
const FALLBACK_OPEN_MS = 300
|
||||
const FALLBACK_CLOSE_MS = 220
|
||||
|
||||
function springEasing(zeta = 0.82, span = 7, samples = 32): string {
|
||||
const wd = Math.sqrt(1 - zeta * zeta)
|
||||
const points: string[] = []
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = (i / samples) * span
|
||||
const x = i === samples ? 1 : 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + (zeta / wd) * Math.sin(wd * t))
|
||||
points.push(String(+x.toFixed(4)))
|
||||
}
|
||||
return `linear(${points.join(', ')})`
|
||||
}
|
||||
|
||||
const supportsLinear = typeof CSS !== 'undefined' && CSS.supports('animation-timing-function', 'linear(0, 1)')
|
||||
const EASE_OPEN = supportsLinear ? springEasing() : 'cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
const EASE_CLOSE = 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
|
||||
// How far the page starts shrunk into the card it grows out of.
|
||||
const PAGE_FROM_SCALE = 0.9
|
||||
|
||||
// The in-app "fast & light" switch (html.motion-reduced, see
|
||||
// stores/appearanceStore.ts) or the OS accessibility setting.
|
||||
const reducedMotion = () =>
|
||||
document.documentElement.classList.contains('motion-reduced') ||
|
||||
(typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches)
|
||||
|
||||
// ── Geometry ──────────────────────────────────────────────────
|
||||
interface Box {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
// Where an <img> actually draws its picture: its box minus padding, then the
|
||||
// object-fit: contain letterboxing every product photo in this app uses. The
|
||||
// ghost has to start and end on the drawn picture, not on the element's box,
|
||||
// or it would visibly snap at both ends.
|
||||
function drawnBox(img: HTMLImageElement, nw: number, nh: number): Box {
|
||||
const r = img.getBoundingClientRect()
|
||||
const cs = getComputedStyle(img)
|
||||
const pl = parseFloat(cs.paddingLeft) || 0
|
||||
const pr = parseFloat(cs.paddingRight) || 0
|
||||
const pt = parseFloat(cs.paddingTop) || 0
|
||||
const pb = parseFloat(cs.paddingBottom) || 0
|
||||
const x = r.left + pl
|
||||
const y = r.top + pt
|
||||
const w = r.width - pl - pr
|
||||
const h = r.height - pt - pb
|
||||
if (cs.objectFit !== 'contain' && cs.objectFit !== 'scale-down') return { x, y, w, h }
|
||||
let s = Math.min(w / nw, h / nh)
|
||||
if (cs.objectFit === 'scale-down') s = Math.min(s, 1)
|
||||
return { x: x + (w - nw * s) / 2, y: y + (h - nh * s) / 2, w: nw * s, h: nh * s }
|
||||
}
|
||||
|
||||
function insetOf(r: DOMRect, radius: string): string {
|
||||
const vw = document.documentElement.clientWidth
|
||||
const vh = window.innerHeight
|
||||
return `inset(${r.top}px ${vw - r.right}px ${vh - r.bottom}px ${r.left}px round ${radius})`
|
||||
}
|
||||
const FULL = 'inset(0px 0px 0px 0px round 0px)'
|
||||
|
||||
function onScreen(r: DOMRect): boolean {
|
||||
return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight
|
||||
}
|
||||
|
||||
function loaded(img: HTMLImageElement | null | undefined): img is HTMLImageElement {
|
||||
return !!img && img.complete && img.naturalWidth > 0
|
||||
}
|
||||
|
||||
function sameSrc(a: HTMLImageElement, b: HTMLImageElement): boolean {
|
||||
return (a.currentSrc || a.src) === (b.currentSrc || b.src)
|
||||
}
|
||||
|
||||
// The gallery image currently showing — the slider keeps every slide in its
|
||||
// track, so pick the one whose centre sits inside the stage.
|
||||
function galleryImage(): HTMLImageElement | null {
|
||||
const stage = sheet.value?.querySelector<HTMLElement>('.pd__stage')
|
||||
if (!stage) return null
|
||||
const s = stage.getBoundingClientRect()
|
||||
const imgs = stage.querySelectorAll<HTMLImageElement>('img')
|
||||
for (const img of imgs) {
|
||||
const r = img.getBoundingClientRect()
|
||||
const cx = r.left + r.width / 2
|
||||
if (cx > s.left && cx < s.right) return img
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function originImage(origin: HTMLElement | null): HTMLImageElement | null {
|
||||
return origin?.querySelector<HTMLImageElement>('img') ?? null
|
||||
}
|
||||
|
||||
// Colours and corner radius the shape morphs between: the card's own fill
|
||||
// (falling back to the page's when the card is transparent) and the page's.
|
||||
function palette(origin: HTMLElement) {
|
||||
const pd = sheet.value?.querySelector<HTMLElement>('.pd')
|
||||
const pageBg = (pd && getComputedStyle(pd).backgroundColor) || '#fff'
|
||||
const cs = getComputedStyle(origin)
|
||||
const cardBg = cs.backgroundColor === 'rgba(0, 0, 0, 0)' ? pageBg : cs.backgroundColor
|
||||
return { pageBg, cardBg, radius: cs.borderRadius || '0px' }
|
||||
}
|
||||
|
||||
function centreOf(r: DOMRect): string {
|
||||
return `${r.left + r.width / 2}px ${r.top + r.height / 2}px`
|
||||
}
|
||||
|
||||
function ghostFrames(from: Box, to: Box): Keyframe[] {
|
||||
const t = (b: Box) => `translate(${b.x - to.x}px, ${b.y - to.y}px) scale(${b.w / to.w}, ${b.h / to.h})`
|
||||
return [{ transform: t(from) }, { transform: t(to) }]
|
||||
}
|
||||
|
||||
function placeGhost(el: HTMLImageElement, at: Box) {
|
||||
el.style.left = `${at.x}px`
|
||||
el.style.top = `${at.y}px`
|
||||
el.style.width = `${at.w}px`
|
||||
el.style.height = `${at.h}px`
|
||||
}
|
||||
|
||||
// ── Run bookkeeping ───────────────────────────────────────────
|
||||
// Each open/close takes a new generation; a run that has been superseded by a
|
||||
// later one skips its own teardown.
|
||||
let gen = 0
|
||||
let running: Animation[] = []
|
||||
let phase: 'idle' | 'opening' | 'open' | 'closing' = 'idle'
|
||||
let hiddenImgs: HTMLElement[] = []
|
||||
|
||||
function hide(el: HTMLElement | null) {
|
||||
if (!el) return
|
||||
el.style.opacity = '0'
|
||||
hiddenImgs.push(el)
|
||||
}
|
||||
function unhideAll() {
|
||||
hiddenImgs.forEach((el) => (el.style.opacity = ''))
|
||||
hiddenImgs = []
|
||||
}
|
||||
|
||||
function stopRunning() {
|
||||
running.forEach((a) => a.cancel())
|
||||
running = []
|
||||
}
|
||||
|
||||
async function settled(anims: Animation[]) {
|
||||
running = anims
|
||||
await Promise.all(anims.map((a) => a.finished.catch(() => undefined)))
|
||||
}
|
||||
|
||||
// The page behind must not scroll while the overlay is up. Set on <html>, not
|
||||
// <body>: SingleProduct's own image preview locks and unlocks <body>, and must
|
||||
// not be able to release this one. Only applied once the overlay is fully
|
||||
// open — toggling overflow on the root re-lays-out the page underneath, which
|
||||
// is a cost the first frame of the morph should not pay.
|
||||
function lockScroll(on: boolean) {
|
||||
document.documentElement.style.overflow = on ? 'hidden' : ''
|
||||
}
|
||||
|
||||
// ── Open ──────────────────────────────────────────────────────
|
||||
async function open(req: ProductMorphRequest) {
|
||||
const my = ++gen
|
||||
stopRunning()
|
||||
unhideAll()
|
||||
closing.value = false
|
||||
moving.value = true
|
||||
phase = 'opening'
|
||||
|
||||
const originImg = originImage(req.origin)
|
||||
const originRect = req.origin?.getBoundingClientRect()
|
||||
const canMorph = !reducedMotion() && !!req.origin && !!originRect && onScreen(originRect)
|
||||
ghostSrc.value = canMorph && loaded(originImg) ? originImg.currentSrc || originImg.src : null
|
||||
|
||||
current.value = req
|
||||
// Mounted, laid out and measured inside the same task as the tap — the first
|
||||
// painted frame is already the first frame of the morph.
|
||||
await nextTick()
|
||||
if (my !== gen || !sheet.value || !veil.value) return
|
||||
|
||||
let anims: Animation[]
|
||||
if (canMorph && originRect && req.origin) {
|
||||
anims = morphOpen(req.origin, originRect, originImg)
|
||||
} else if (reducedMotion()) {
|
||||
anims = [
|
||||
sheet.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
|
||||
veil.value.animate({ opacity: [0, 1] }, { duration: 160, easing: 'ease-out', fill: 'both' }),
|
||||
]
|
||||
} else {
|
||||
anims = [
|
||||
sheet.value.animate(
|
||||
{ transform: ['translateY(6%)', 'none'], opacity: [0, 1] },
|
||||
{ duration: FALLBACK_OPEN_MS, easing: EASE_OUT, fill: 'both' },
|
||||
),
|
||||
veil.value.animate({ opacity: [0, 1] }, { duration: FALLBACK_OPEN_MS, easing: 'ease-out', fill: 'both' }),
|
||||
]
|
||||
}
|
||||
|
||||
await settled(anims)
|
||||
if (my !== gen) return
|
||||
// The end state of every animation equals the resting CSS, so dropping them
|
||||
// changes nothing on screen — it just leaves no clip-path or composited
|
||||
// layers behind while the page is being read and scrolled.
|
||||
stopRunning()
|
||||
unhideAll()
|
||||
ghostSrc.value = null
|
||||
moving.value = false
|
||||
phase = 'open'
|
||||
lockScroll(true)
|
||||
}
|
||||
|
||||
function morphOpen(origin: HTMLElement, originRect: DOMRect, originImg: HTMLImageElement | null): Animation[] {
|
||||
const sheetEl = sheet.value!
|
||||
const shapeEl = shape.value!
|
||||
const veilEl = veil.value!
|
||||
const { pageBg, cardBg, radius } = palette(origin)
|
||||
|
||||
// Measure everything BEFORE any animation starts: once the page is scaled,
|
||||
// the gallery's rect would come back scaled too.
|
||||
const targetImg = galleryImage()
|
||||
const ghostEl = ghost.value
|
||||
let flight: { from: Box; to: Box } | null = null
|
||||
if (ghostEl && loaded(originImg) && targetImg && sameSrc(originImg, targetImg)) {
|
||||
const nw = originImg.naturalWidth
|
||||
const nh = originImg.naturalHeight
|
||||
const to = drawnBox(targetImg, nw, nh)
|
||||
if (to.w > 0 && to.h > 0) flight = { from: drawnBox(originImg, nw, nh), to }
|
||||
}
|
||||
|
||||
sheetEl.style.transformOrigin = centreOf(originRect)
|
||||
|
||||
const anims: Animation[] = [
|
||||
shapeEl.animate(
|
||||
{ clipPath: [insetOf(originRect, radius), FULL], backgroundColor: [cardBg, pageBg] },
|
||||
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
|
||||
),
|
||||
veilEl.animate({ opacity: [0, 1] }, { duration: OPEN_MS * 0.6, easing: 'ease-out', fill: 'both' }),
|
||||
// The page grows out of the card's centre alongside the shape, and fades
|
||||
// in a beat behind it — the first frames read as the card lifting, then
|
||||
// the page arriving inside it.
|
||||
sheetEl.animate(
|
||||
{ transform: [`scale(${PAGE_FROM_SCALE})`, 'none'] },
|
||||
{ duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' },
|
||||
),
|
||||
sheetEl.animate(
|
||||
{ opacity: [0, 1] },
|
||||
{ duration: OPEN_MS * 0.55, delay: OPEN_MS * 0.1, easing: 'cubic-bezier(0.3, 0, 0.2, 1)', fill: 'both' },
|
||||
),
|
||||
]
|
||||
|
||||
if (ghostEl && flight && targetImg) {
|
||||
placeGhost(ghostEl, flight.to)
|
||||
hide(targetImg)
|
||||
anims.push(ghostEl.animate(ghostFrames(flight.from, flight.to), { duration: OPEN_MS, easing: EASE_OPEN, fill: 'both' }))
|
||||
} else {
|
||||
ghostSrc.value = null
|
||||
}
|
||||
|
||||
return anims
|
||||
}
|
||||
|
||||
// ── Close ─────────────────────────────────────────────────────
|
||||
async function close() {
|
||||
if (!current.value || phase === 'closing') return
|
||||
const my = ++gen
|
||||
const wasOpening = phase === 'opening'
|
||||
phase = 'closing'
|
||||
closing.value = true
|
||||
moving.value = true
|
||||
|
||||
let anims: Animation[]
|
||||
if (wasOpening && running.length) {
|
||||
// Closed before it finished opening: play the same motion back from
|
||||
// wherever it got to, rather than jumping to open and morphing from there.
|
||||
running.forEach((a) => a.reverse())
|
||||
anims = running
|
||||
} else {
|
||||
stopRunning()
|
||||
unhideAll()
|
||||
anims = await closeAnimations()
|
||||
}
|
||||
|
||||
await settled(anims)
|
||||
if (my !== gen) return
|
||||
teardown()
|
||||
}
|
||||
|
||||
async function closeAnimations(): Promise<Animation[]> {
|
||||
const sheetEl = sheet.value
|
||||
const shapeEl = shape.value
|
||||
const veilEl = veil.value
|
||||
if (!sheetEl || !shapeEl || !veilEl) return []
|
||||
|
||||
if (reducedMotion()) {
|
||||
return [
|
||||
sheetEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
|
||||
veilEl.animate({ opacity: [1, 0] }, { duration: 140, easing: 'ease-in', fill: 'forwards' }),
|
||||
]
|
||||
}
|
||||
|
||||
const origin = current.value?.origin ?? null
|
||||
const originRect = origin?.isConnected ? origin.getBoundingClientRect() : null
|
||||
|
||||
if (!origin || !originRect || !onScreen(originRect)) {
|
||||
return [
|
||||
sheetEl.animate(
|
||||
{ transform: ['none', 'translateY(6%)'], opacity: [1, 0] },
|
||||
{ duration: FALLBACK_CLOSE_MS, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' },
|
||||
),
|
||||
veilEl.animate({ opacity: [1, 0] }, { duration: FALLBACK_CLOSE_MS, easing: 'ease-in', fill: 'forwards' }),
|
||||
]
|
||||
}
|
||||
|
||||
const { pageBg, cardBg, radius } = palette(origin)
|
||||
|
||||
// Measured before anything moves, for the same reason as on the way in.
|
||||
const originImg = originImage(origin)
|
||||
const fromImg = galleryImage()
|
||||
let flight: { from: Box; to: Box } | null = null
|
||||
if (loaded(originImg) && fromImg && sameSrc(originImg, fromImg) && onScreen(fromImg.getBoundingClientRect())) {
|
||||
const nw = originImg.naturalWidth
|
||||
const nh = originImg.naturalHeight
|
||||
const from = drawnBox(fromImg, nw, nh)
|
||||
if (from.w > 0 && from.h > 0) flight = { from, to: drawnBox(originImg, nw, nh) }
|
||||
}
|
||||
if (flight && originImg) {
|
||||
ghostSrc.value = originImg.currentSrc || originImg.src
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
sheetEl.style.transformOrigin = centreOf(originRect)
|
||||
|
||||
const anims: Animation[] = [
|
||||
// The shape fades over its last stretch so the real card — title and all —
|
||||
// crossfades in under it instead of popping in after it lands.
|
||||
shapeEl.animate(
|
||||
[
|
||||
{ clipPath: FULL, backgroundColor: pageBg, opacity: 1 },
|
||||
{ opacity: 1, offset: 0.7 },
|
||||
{ clipPath: insetOf(originRect, radius), backgroundColor: cardBg, opacity: 0 },
|
||||
],
|
||||
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
|
||||
),
|
||||
sheetEl.animate(
|
||||
{ transform: ['none', `scale(${PAGE_FROM_SCALE})`] },
|
||||
{ duration: CLOSE_MS, easing: EASE_CLOSE, fill: 'forwards' },
|
||||
),
|
||||
sheetEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS * 0.45, easing: 'ease-in', fill: 'forwards' }),
|
||||
veilEl.animate({ opacity: [1, 0] }, { duration: CLOSE_MS, easing: 'ease-out', fill: 'forwards' }),
|
||||
]
|
||||
|
||||
// The photo flies home only if the gallery is still on screen and showing
|
||||
// the same picture the card does; otherwise the shape alone carries it back.
|
||||
const ghostEl = ghost.value
|
||||
if (ghostEl && flight && fromImg) {
|
||||
// Based on the gallery box this time, so the frames run identity → card.
|
||||
placeGhost(ghostEl, flight.from)
|
||||
hide(fromImg)
|
||||
anims.push(
|
||||
ghostEl.animate(ghostFrames(flight.to, flight.from).reverse(), {
|
||||
duration: CLOSE_MS,
|
||||
easing: EASE_CLOSE,
|
||||
fill: 'forwards',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return anims
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
stopRunning()
|
||||
unhideAll()
|
||||
ghostSrc.value = null
|
||||
moving.value = false
|
||||
closing.value = false
|
||||
phase = 'idle'
|
||||
current.value = null
|
||||
lockScroll(false)
|
||||
if (productMorph.value) productMorph.value = null
|
||||
}
|
||||
|
||||
// ── Wiring ────────────────────────────────────────────────────
|
||||
watch(productMorph, (req) => {
|
||||
if (req) open(req)
|
||||
else if (current.value) close()
|
||||
})
|
||||
|
||||
// Anything inside the page that navigates (the cart button, the category pill)
|
||||
// leaves the overlay behind; it goes with the screen it was opened over.
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (!current.value) return
|
||||
++gen
|
||||
teardown()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRunning()
|
||||
unhideAll()
|
||||
lockScroll(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
// Same layer the Drawer it replaces sat on; SingleProduct's own full-screen
|
||||
// image preview is 2000 and must stay above this.
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.pm.is-closing {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pm__veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(14, 15, 18, 0.32);
|
||||
}
|
||||
|
||||
// Only drawn while something is moving; at rest the page covers it anyway.
|
||||
.pm__shape {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
}
|
||||
.pm.is-moving .pm__shape {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// The scroller for pages that do not scroll themselves (v1's SingleProduct
|
||||
// grows with its content; v2's is its own 100vh scroller and never overflows
|
||||
// this). The page's transform is applied HERE rather than on anything inside:
|
||||
// this box is already pinned to the viewport, so a `position: fixed` action bar
|
||||
// inside the page stays exactly where it is while the transform is running.
|
||||
.pm__sheet {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// Backdrop blurs are re-computed on every frame their layer moves, and the page
|
||||
// has around ten of them (header buttons, action bar…). Their surfaces are
|
||||
// already ~90% opaque, so dropping the blur for the length of the motion is
|
||||
// invisible — and it is the single biggest cost the moving page had.
|
||||
.pm.is-moving :deep(*) {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.pm__ghost {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
transform-origin: 0 0;
|
||||
object-fit: fill;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
@@ -321,8 +321,11 @@ import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { useFiltersStore } from '../../stores/filtersStore'
|
||||
import { useCartStore, type CartItem } from '../../stores/cartStore'
|
||||
import { isUserLoggedIn } from '../../utils/checkAccess'
|
||||
import type { ProductSummary } from '../../api/client'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
// `initial`: the card's own ProductSummary when opened from a list (see
|
||||
// ProductMorph.vue) — lets the page render whole on its first frame.
|
||||
const props = defineProps<{ id: string; initial?: ProductSummary | null }>()
|
||||
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
|
||||
|
||||
const router = useRouter()
|
||||
@@ -330,7 +333,7 @@ const favorites = useFavoritesStore()
|
||||
const filters = useFiltersStore()
|
||||
const cart = useCartStore()
|
||||
|
||||
// Used two ways, as in legacy: embedded in a bottom Drawer by the product
|
||||
// Used two ways, as in legacy: embedded in the ProductMorph overlay by the product
|
||||
// cards, and as a standalone page for deep links (/product/:id). In the drawer
|
||||
// case the parent listens for closeDrawer; on the routed page there is no
|
||||
// parent, so fall back to history.back().
|
||||
@@ -366,7 +369,7 @@ function onScroll() {
|
||||
headerSolid.value = el.scrollTop > Math.max(galleryHeight - HEADER_SOLID_OFFSET_PX, 0)
|
||||
}
|
||||
|
||||
const productDetail = useProductDetail(props.id)
|
||||
const productDetail = useProductDetail(props.id, props.initial)
|
||||
const loading = productDetail.loading
|
||||
const flag = productDetail.flag
|
||||
|
||||
@@ -532,7 +535,7 @@ onBeforeUnmount(() => {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: @canvas;
|
||||
background: @page;
|
||||
// Momentum scrolling on iOS, and stop a scroll at either end from chaining
|
||||
// out to the page (or the drawer) behind.
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -579,7 +582,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.pd__header.is-solid {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
background: alpha(@canvas, 88%);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom-color: @hairline;
|
||||
}
|
||||
@@ -640,7 +643,7 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: @ink;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: alpha(@canvas, 90%);
|
||||
backdrop-filter: blur(10px) saturate(140%);
|
||||
box-shadow: 0 10px 26px -16px rgba(20, 22, 26, 0.7);
|
||||
transition: transform 0.18s ease, color 0.28s ease, background 0.28s ease,
|
||||
@@ -694,7 +697,7 @@ onBeforeUnmount(() => {
|
||||
min-height: 320px;
|
||||
max-height: 460px;
|
||||
overflow: hidden;
|
||||
background: @hairlineSoft;
|
||||
background: @photoStage;
|
||||
// Horizontal drags are the gallery's; vertical ones still scroll the page.
|
||||
touch-action: pan-y;
|
||||
}
|
||||
@@ -1003,7 +1006,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.pd__stock.is-in {
|
||||
color: @green;
|
||||
background: fade(@green, 8%);
|
||||
background: alpha(@green, 8%);
|
||||
}
|
||||
.pd__stock.is-pre {
|
||||
color: @wine;
|
||||
@@ -1085,7 +1088,7 @@ onBeforeUnmount(() => {
|
||||
color: @inkFaint;
|
||||
}
|
||||
.pd__figure--accent .pd__figureLabel {
|
||||
color: fade(@wine, 65%);
|
||||
color: alpha(@wine, 65%);
|
||||
}
|
||||
|
||||
/* ── Variant chips ───────────────────────────────────────── */
|
||||
@@ -1122,7 +1125,7 @@ onBeforeUnmount(() => {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.pd__chip.is-on {
|
||||
color: #fff;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
}
|
||||
@@ -1218,7 +1221,7 @@ onBeforeUnmount(() => {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: alpha(@canvas, 90%);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
border-top: 1px solid @hairline;
|
||||
}
|
||||
@@ -1304,7 +1307,7 @@ onBeforeUnmount(() => {
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- Gram-range filter: real, bounds derived from the actual min/max
|
||||
rashWeightGrams across all variants (rather than a customer-specific
|
||||
server-provided bound). Still uses ant-design-vue's Slider for the actual
|
||||
drag/range behaviour, restyled via :deep() to the wine accent.
|
||||
drag/range behaviour, restyled to the app's accent.
|
||||
- DROPPED: color filter — no color concept exists anywhere in the new
|
||||
Product/ProductVariant schema, not a stubbed-off feature like promotions,
|
||||
genuinely nothing to filter by.
|
||||
@@ -23,72 +23,139 @@
|
||||
concept in the new Customer model).
|
||||
- DROPPED: discount toggle — no promotions data exists (see useProduct.ts).
|
||||
|
||||
Top padding (120px) accounts for PageBar + the search input row with no
|
||||
chips row below it (chips are hidden while this sheet is open) — same
|
||||
bar-chrome math SearchView/OrdersView use for their own #below content.
|
||||
Presentation: a bottom sheet over a dimmed, see-through backdrop. Full width
|
||||
and attached to the bottom edge — not a floating island, so it has room to
|
||||
take more filters later — and only as tall as its content, up to 88% of the
|
||||
screen, past which the body scrolls under a fixed header and action bar.
|
||||
It replaced an in-place swap of the search page's body (results hidden with
|
||||
v-show, the bar retitled and its back button hijacked, the tab bar hidden),
|
||||
which was neither a page nor a modal. Closes on the backdrop, a swipe down
|
||||
on its header, the × button, or the hardware/browser back
|
||||
(close-on-popstate).
|
||||
|
||||
STRUCTURE IS SHARED, LOOK IS NOT. The template and script here are kept
|
||||
identical to user-app's Filters.vue; each app styles the same markup in its
|
||||
own design language (this one: white sheet with the 32px top radius the
|
||||
detail page's sheet uses, borderless @hairlineSoft panels via DropDown, @ink
|
||||
pill buttons and chips, squircle controls, @wine accent).
|
||||
|
||||
Choices are a DRAFT until "اعمال فیلتر": nothing touches the filters store
|
||||
while the sheet is open, so dragging the gram slider no longer re-filters and
|
||||
re-renders the whole result grid behind it on every pointer move (it did, and
|
||||
that was most of the lag). Dismissing without applying discards the draft.
|
||||
"پاک کردن فیلترها" clears only what this sheet owns (brands, gram range) —
|
||||
it used to reset the whole query, wiping the search text and the category
|
||||
chip along with it.
|
||||
-->
|
||||
<template>
|
||||
<section class="filt">
|
||||
<div class="filt__body">
|
||||
<DropDown text="فیلتر بر اساس برند">
|
||||
<div v-if="availableBrands.length" class="filt__chips">
|
||||
<button
|
||||
v-for="item in availableBrands"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="filt__chip"
|
||||
:class="{ 'is-on': modelValue.brands.includes(item.id) }"
|
||||
@click="toggleBrand(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
<Popup
|
||||
:show="show"
|
||||
position="bottom"
|
||||
teleport="body"
|
||||
transition="fSheet-slide"
|
||||
class="fSheet"
|
||||
overlay-class="fSheet__overlay"
|
||||
close-on-popstate
|
||||
@update:show="emit('update:show', $event)"
|
||||
@closed="dragY = 0"
|
||||
>
|
||||
<div
|
||||
class="fSheet__card"
|
||||
:class="{ 'is-dragging': dragging }"
|
||||
:style="cardStyle"
|
||||
dir="rtl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="fSheet-title"
|
||||
>
|
||||
<!-- The drag handle area. Swipe-to-dismiss starts only here, so a
|
||||
vertical swipe on the body still scrolls the filters. -->
|
||||
<header
|
||||
class="fSheet__head"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchmove.passive="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchEnd"
|
||||
>
|
||||
<span class="fSheet__grip" aria-hidden="true"></span>
|
||||
<div class="fSheet__headRow">
|
||||
<h3 id="fSheet-title" class="fSheet__title">
|
||||
فیلترها
|
||||
<span v-if="draftCount" class="fSheet__count">{{ draftCount }}</span>
|
||||
</h3>
|
||||
<button type="button" class="fSheet__close" aria-label="بستن" @click="close">
|
||||
<Icon icon="vuesax-linear:add" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="filt__empty">برندی برای فیلتر موجود نیست.</p>
|
||||
</DropDown>
|
||||
</header>
|
||||
|
||||
<DropDown text="فیلتر بر اساس گرم">
|
||||
<div class="filt__gram">
|
||||
<Slider
|
||||
v-model:value="(modelValue.limit_gram as unknown as [number, number])"
|
||||
:step="0.01"
|
||||
range
|
||||
reverse
|
||||
:min="gramBounds[0]"
|
||||
:max="gramBounds[1]"
|
||||
/>
|
||||
<div class="filt__gramFigures" v-if="modelValue.limit_gram.length > 0">
|
||||
<span class="filt__gramFigure">
|
||||
<i>کمترین</i>
|
||||
{{ modelValue.limit_gram[0] }} گرم
|
||||
</span>
|
||||
<span class="filt__gramFigure">
|
||||
<i>بیشترین</i>
|
||||
{{ modelValue.limit_gram[1] }} گرم
|
||||
</span>
|
||||
<div class="fSheet__body">
|
||||
<DropDown text="برند" default-open>
|
||||
<div v-if="availableBrands.length" class="fSheet__chips">
|
||||
<button
|
||||
v-for="item in availableBrands"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="fSheet__chip"
|
||||
:class="{ 'is-on': draftBrands.includes(item.id) }"
|
||||
:aria-pressed="draftBrands.includes(item.id)"
|
||||
@click="toggleBrand(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DropDown>
|
||||
</div>
|
||||
<p v-else class="fSheet__empty">برندی برای فیلتر موجود نیست.</p>
|
||||
</DropDown>
|
||||
|
||||
<div class="filt__bar">
|
||||
<div class="filt__barInner">
|
||||
<button type="button" class="filt__clear" @click="closeFilters(false)">پاک کردن فیلترها</button>
|
||||
<button type="button" class="filt__apply" @click="closeFilters(true)">اعمال فیلتر</button>
|
||||
<DropDown text="وزن (گرم)" default-open>
|
||||
<div class="fSheet__gram">
|
||||
<!-- Tooltips off: they render in ant's own layer (z-index 1070),
|
||||
underneath this sheet (vant's 2000+), and the figures below
|
||||
already show both values live. -->
|
||||
<Slider
|
||||
:value="draftGram"
|
||||
:step="0.01"
|
||||
range
|
||||
reverse
|
||||
:min="gramBounds[0]"
|
||||
:max="gramBounds[1]"
|
||||
:tooltip-visible="false"
|
||||
@update:value="onGram"
|
||||
/>
|
||||
<div class="fSheet__gramFigures">
|
||||
<span class="fSheet__gramFigure">
|
||||
<i>کمترین</i>
|
||||
<b>{{ fmt(draftGram[0]) }}</b> گرم
|
||||
</span>
|
||||
<span class="fSheet__gramFigure">
|
||||
<i>بیشترین</i>
|
||||
<b>{{ fmt(draftGram[1]) }}</b> گرم
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropDown>
|
||||
</div>
|
||||
|
||||
<footer class="fSheet__bar">
|
||||
<button type="button" class="fSheet__clear" @click="clear">پاک کردن</button>
|
||||
<button type="button" class="fSheet__apply" @click="apply">اعمال فیلتر</button>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</Popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Popup } from 'vant'
|
||||
import { Slider } from 'ant-design-vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import DropDown from '../ui/DropDown.vue'
|
||||
import type { FiltersQuery } from '../../stores/filtersStore'
|
||||
import type { ProductSummary } from '../../api/client'
|
||||
|
||||
const props = withDefaults(defineProps<{ modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>(), {})
|
||||
const props = defineProps<{ show: boolean; modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', value: { resetFilters: boolean }): void
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'update:filterKeys', value: string[]): void
|
||||
}>()
|
||||
|
||||
@@ -106,185 +173,413 @@ const gramBounds = computed((): [number, number] => {
|
||||
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
|
||||
})
|
||||
|
||||
// The Slider's `range` mode needs a real [start, end] value to place its two
|
||||
// handles — filtersStore's default `limit_gram: []` has neither, so until
|
||||
// this seeds it, ant-design-vue's Slider has nothing to derive handle
|
||||
// positions from and renders no handles at all. Only seeds while still
|
||||
// empty, so it never clobbers a value the user already dragged.
|
||||
// ── Draft ─────────────────────────────────────────────────────
|
||||
const draftBrands = ref<string[]>([])
|
||||
// Always a real [start, end] pair: ant-design-vue's range Slider places no
|
||||
// handles at all without one.
|
||||
const draftGram = ref<[number, number]>([...gramBounds.value])
|
||||
// Until the user drags, the range follows the catalogue's bounds (they can
|
||||
// still arrive after the sheet first opens, while the list is loading).
|
||||
let gramTouched = false
|
||||
|
||||
function loadDraft() {
|
||||
draftBrands.value = [...props.modelValue.brands]
|
||||
const g = props.modelValue.limit_gram
|
||||
gramTouched = g.length === 2
|
||||
draftGram.value = g.length === 2 ? [g[0], g[1]] : [...gramBounds.value]
|
||||
}
|
||||
|
||||
watch(
|
||||
gramBounds,
|
||||
(bounds) => {
|
||||
if (props.modelValue.limit_gram.length === 0) {
|
||||
props.modelValue.limit_gram = [...bounds]
|
||||
}
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) loadDraft()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const toggleBrand = (id: string) => {
|
||||
const brands = props.modelValue.brands
|
||||
const index = brands.indexOf(id)
|
||||
if (index === -1) brands.push(id)
|
||||
else brands.splice(index, 1)
|
||||
watch(gramBounds, (bounds) => {
|
||||
if (!gramTouched) draftGram.value = [...bounds]
|
||||
})
|
||||
|
||||
function onGram(value: [number, number] | number) {
|
||||
if (!Array.isArray(value)) return
|
||||
draftGram.value = [value[0], value[1]]
|
||||
gramTouched = true
|
||||
}
|
||||
|
||||
const closeFilters = (sendQuery: boolean): void => {
|
||||
const filterKeys: string[] = []
|
||||
if (sendQuery) {
|
||||
if (props.modelValue.brands.length > 0) filterKeys.push('brands')
|
||||
if (
|
||||
props.modelValue.limit_gram[0] !== undefined &&
|
||||
props.modelValue.limit_gram[1] !== undefined &&
|
||||
(props.modelValue.limit_gram[0] !== gramBounds.value[0] || props.modelValue.limit_gram[1] !== gramBounds.value[1])
|
||||
) {
|
||||
filterKeys.push('limit_gram')
|
||||
}
|
||||
}
|
||||
if (sendQuery && filterKeys.length > 0) {
|
||||
emit('update:filterKeys', filterKeys)
|
||||
emit('change', { resetFilters: false })
|
||||
} else {
|
||||
emit('update:filterKeys', [])
|
||||
emit('change', { resetFilters: true })
|
||||
}
|
||||
// A range that still spans the whole catalogue filters nothing out, so it is
|
||||
// not counted as a filter (and is stored as "no range" on apply).
|
||||
const gramActive = computed(() => {
|
||||
const [lo, hi] = draftGram.value
|
||||
const [min, max] = gramBounds.value
|
||||
return Math.abs(lo - min) > 0.001 || Math.abs(hi - max) > 0.001
|
||||
})
|
||||
|
||||
const draftCount = computed(() => draftBrands.value.length + (gramActive.value ? 1 : 0))
|
||||
|
||||
function toggleBrand(id: string) {
|
||||
const i = draftBrands.value.indexOf(id)
|
||||
draftBrands.value = i === -1 ? [...draftBrands.value, id] : draftBrands.value.filter((b) => b !== id)
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
return n % 1 === 0 ? String(n) : String(+n.toFixed(2))
|
||||
}
|
||||
|
||||
// ── Commit ────────────────────────────────────────────────────
|
||||
// Always assigns fresh arrays rather than mutating the store's in place — see
|
||||
// filtersStore's reset() for why sharing them went wrong.
|
||||
function apply() {
|
||||
const q = props.modelValue
|
||||
q.brands = [...draftBrands.value]
|
||||
q.limit_gram = gramActive.value ? [...draftGram.value] : []
|
||||
|
||||
const keys: string[] = []
|
||||
if (q.brands.length) keys.push('brands')
|
||||
if (q.limit_gram.length) keys.push('limit_gram')
|
||||
emit('update:filterKeys', keys)
|
||||
close()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
draftBrands.value = []
|
||||
draftGram.value = [...gramBounds.value]
|
||||
gramTouched = false
|
||||
apply()
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
// ── Swipe down to dismiss (header only) ───────────────────────
|
||||
// The sheet follows the finger down, rubber-bands up, and on release either
|
||||
// springs back or leaves from where it was let go.
|
||||
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
|
||||
dragY.value = dy > 0 ? dy : dy * 0.12
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!dragging.value) return
|
||||
dragging.value = false
|
||||
const velocity = dragY.value / Math.max(1, Date.now() - startTime)
|
||||
if (dragY.value > DISMISS_DISTANCE || (dragY.value > 30 && velocity > DISMISS_VELOCITY)) close()
|
||||
else dragY.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.filt {
|
||||
background: @canvas;
|
||||
min-height: 100vh;
|
||||
<style lang="less">
|
||||
// Global, not scoped: the popup is teleported to <body>. Everything is under
|
||||
// the fSheet prefix.
|
||||
|
||||
/* ── Shell (same in both apps) ──────────────────────────────── */
|
||||
// See-through dim, no blur: blurring the page behind a fading overlay is
|
||||
// recomputed every frame and is what made sheets janky on phones.
|
||||
.fSheet__overlay {
|
||||
background: rgba(20, 22, 26, 0.42);
|
||||
}
|
||||
|
||||
.filt__body {
|
||||
.fSheet.van-popup {
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// The iOS sheet curve: fast out of the gate, long soft landing. Transform only.
|
||||
.fSheet-slide-enter-active,
|
||||
.fSheet-slide-leave-active {
|
||||
will-change: transform;
|
||||
}
|
||||
.fSheet-slide-enter-active {
|
||||
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
.fSheet-slide-leave-active {
|
||||
transition: transform 0.24s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
.fSheet-slide-enter-from,
|
||||
.fSheet-slide-leave-to {
|
||||
transform: translate3d(0, 100%, 0);
|
||||
}
|
||||
|
||||
.fSheet__card {
|
||||
position: relative;
|
||||
// Phone: edge to edge. Wider screens: a centred column, same max width the
|
||||
// app's pages use.
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: 120px 16px 110px;
|
||||
max-height: 88vh;
|
||||
max-height: 88dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
&.is-dragging {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fSheet__head {
|
||||
flex: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.fSheet__grip {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.fSheet__headRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filt__chips {
|
||||
.fSheet__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fSheet__count {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fSheet__close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.fSheet__close:active {
|
||||
transform: rotate(45deg) scale(0.92);
|
||||
}
|
||||
|
||||
.fSheet__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fSheet__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filt__chip {
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
.fSheet__chip {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: @inkSoft;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: color 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, transform 0.15s ease;
|
||||
}
|
||||
.fSheet__chip:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.filt__chip.is-on {
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
}
|
||||
|
||||
.filt__chip:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.filt__empty {
|
||||
.fSheet__empty {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.filt__gram {
|
||||
padding: 4px 6px 0;
|
||||
}
|
||||
|
||||
.filt__gram :deep(.ant-slider-track) {
|
||||
background-color: @wine;
|
||||
}
|
||||
|
||||
.filt__gram :deep(.ant-slider-handle) {
|
||||
border-color: @wine;
|
||||
}
|
||||
|
||||
.filt__gram :deep(.ant-slider-rail) {
|
||||
background-color: @hairline;
|
||||
}
|
||||
|
||||
.filt__gramFigures {
|
||||
.fSheet__gramFigures {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.filt__gramFigure {
|
||||
font-size: 12px;
|
||||
.fSheet__gramFigure i {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.fSheet__bar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.fSheet__clear,
|
||||
.fSheet__apply {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.fSheet__clear:active,
|
||||
.fSheet__apply:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ── Look: user-app-v2 ──────────────────────────────────────── */
|
||||
// The detail page's sheet: white, 32px top radius, the same grip.
|
||||
.fSheet__card {
|
||||
border-radius: 32px 32px 0 0;
|
||||
background: @canvas;
|
||||
box-shadow: 0 -18px 40px -30px rgba(20, 22, 26, 0.55);
|
||||
}
|
||||
|
||||
.fSheet__head {
|
||||
padding: 8px 18px 12px;
|
||||
}
|
||||
|
||||
.fSheet__grip {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 999px;
|
||||
background: @hairline;
|
||||
}
|
||||
|
||||
.fSheet__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.filt__gramFigure i {
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: @inkFaint;
|
||||
padding-inline-end: 4px;
|
||||
.fSheet__count {
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.filt__bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
border-top: 1px solid @hairline;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.filt__barInner {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filt__clear,
|
||||
.filt__apply {
|
||||
flex: 1;
|
||||
padding: 13px 18px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 14px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.filt__clear {
|
||||
color: @inkSoft;
|
||||
// PageBar's / the detail header's squircle control.
|
||||
.fSheet__close {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
color: @ink;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.fSheet__body {
|
||||
gap: 10px;
|
||||
padding: 2px 16px 16px;
|
||||
}
|
||||
|
||||
// The detail page's variant chips.
|
||||
.fSheet__chip {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @inkMuted;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
}
|
||||
.fSheet__chip.is-on {
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
}
|
||||
|
||||
.fSheet__gram {
|
||||
padding: 2px 4px 0;
|
||||
}
|
||||
// A step darker than @hairline: the rail sits on DropDown's @hairlineSoft panel.
|
||||
.fSheet__gram .ant-slider-rail {
|
||||
background-color: @hairlineStrong;
|
||||
}
|
||||
.fSheet__gram .ant-slider-track {
|
||||
background-color: @wine;
|
||||
}
|
||||
.fSheet__gram .ant-slider-handle {
|
||||
border-color: @wine;
|
||||
}
|
||||
|
||||
.fSheet__gramFigures {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
// The detail page's figure tiles, on white since they sit on a grey panel.
|
||||
.fSheet__gramFigure {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 13px;
|
||||
background: @canvas;
|
||||
font-size: 11px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
.fSheet__gramFigure i {
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.fSheet__gramFigure b {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
// The detail page's action bar: an outlined pill beside the dark primary pill.
|
||||
.fSheet__bar {
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
background: @canvas;
|
||||
border-top: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.fSheet__clear,
|
||||
.fSheet__apply {
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.fSheet__clear {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 22px;
|
||||
color: @ink;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.filt__apply {
|
||||
color: #fff;
|
||||
.fSheet__apply {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
}
|
||||
|
||||
.filt__clear:active,
|
||||
.filt__apply:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,20 +4,44 @@
|
||||
raw Tailwind utility classes with legacy tokens (text-primary,
|
||||
border-lightGray/20), the one accordion in the app that didn't match the
|
||||
LESS-token look every other surface uses.
|
||||
|
||||
FIXED — the collapse itself. It used to v-if the body in and out and animate
|
||||
`max-height` from 0 to a hard-coded 240px, which broke three ways: content
|
||||
taller than 240px was cut off for the whole animation and then jumped to full
|
||||
height at the end; content shorter than 240px opened fast and then closed
|
||||
with a dead pause (the first stretch of the shrink happened above the real
|
||||
height, so nothing moved); and the close used `animation: … reverse` on a
|
||||
`leave-to` class, which Vue does not time leave transitions by, so it
|
||||
usually just snapped shut. The body's own padding also popped in and out.
|
||||
|
||||
Now the body stays mounted and the panel animates `grid-template-rows` from
|
||||
0fr to 1fr — the browser interpolates to the content's real height, whatever
|
||||
it is, in both directions, with no measuring and no cap. All the padding
|
||||
below the header lives inside the clipped row, so it is part of what
|
||||
animates. Browsers too old to interpolate grid rows (pre-2022) simply open
|
||||
and close without the slide.
|
||||
|
||||
STRUCTURE IS SHARED, LOOK IS NOT: the template, script and the collapse
|
||||
mechanics are identical in user-app and user-app-v2; only the "Look" block
|
||||
at the bottom differs, each in its own app's design language.
|
||||
-->
|
||||
<template>
|
||||
<div class="dd" :class="{ 'is-open': show }">
|
||||
<button type="button" class="dd__head" @click="show = !show">
|
||||
<button type="button" class="dd__head" :aria-expanded="show" @click="show = !show">
|
||||
<h3 class="dd__title">
|
||||
<slot name="text">{{ text }}</slot>
|
||||
</h3>
|
||||
<Icon icon="vuesax-linear:arrow-down" class="dd__chevron" size="18" />
|
||||
<span class="dd__chevron" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-down" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
<transition name="drop" mode="out-in" appear>
|
||||
<div class="dd__body" v-if="show">
|
||||
<slot />
|
||||
<div class="dd__panel" :inert="!show">
|
||||
<div class="dd__clip">
|
||||
<div class="dd__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,21 +49,16 @@
|
||||
import { ref } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
|
||||
withDefaults(defineProps<{ text?: string }>(), {})
|
||||
const show = ref(false)
|
||||
const props = withDefaults(defineProps<{ text?: string; defaultOpen?: boolean }>(), { defaultOpen: false })
|
||||
const show = ref(props.defaultOpen)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dd {
|
||||
padding: 15px;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 16px;
|
||||
background: @canvas;
|
||||
}
|
||||
|
||||
.dd.is-open {
|
||||
border-color: @wineSoft;
|
||||
}
|
||||
/* ── Mechanics (same in both apps) ──────────────────────────── */
|
||||
// One curve for the whole collapse: the iOS sheet curve the app's sheets use —
|
||||
// quick off the mark, soft landing.
|
||||
@dd-ease: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
@dd-time: 0.34s;
|
||||
|
||||
.dd__head {
|
||||
display: flex;
|
||||
@@ -48,50 +67,96 @@ const show = ref(false)
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
text-align: start;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.dd__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dd__chevron {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: transform @dd-time @dd-ease, color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.dd.is-open .dd__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dd__panel {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows @dd-time @dd-ease;
|
||||
}
|
||||
.dd.is-open .dd__panel {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
// The row's content box: `min-height: 0` is what lets the 0fr row actually
|
||||
// shrink below the content's height, and the clip hides what is outside it.
|
||||
.dd__clip {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Fades and settles in a touch behind the height, and out a touch ahead of it,
|
||||
// so the content never shows half-cut at the moving edge.
|
||||
.dd__body {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transition: opacity 0.18s ease, transform @dd-time @dd-ease;
|
||||
}
|
||||
.dd.is-open .dd__body {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: opacity 0.24s ease 0.06s, transform @dd-time @dd-ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dd__panel,
|
||||
.dd__body,
|
||||
.dd__chevron {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Look: user-app-v2 ──────────────────────────────────────── */
|
||||
// A borderless @hairlineSoft panel, the detail page's spec-table radius; the
|
||||
// chevron sits in a small white squircle that fills @ink when open, the way
|
||||
// the app's chips do when chosen.
|
||||
.dd {
|
||||
border-radius: 22px;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.dd__head {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.dd__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.dd__chevron {
|
||||
flex: 0 0 auto;
|
||||
color: @inkFaint;
|
||||
transition: transform 0.2s ease;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
color: @inkMuted;
|
||||
background: @canvas;
|
||||
}
|
||||
|
||||
.dd.is-open .dd__chevron {
|
||||
transform: rotate(180deg);
|
||||
color: @wine;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
}
|
||||
|
||||
.dd__body {
|
||||
overflow: hidden;
|
||||
padding-top: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.drop-enter-active {
|
||||
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
.drop-leave-to {
|
||||
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1) reverse;
|
||||
}
|
||||
@keyframes drop {
|
||||
0% {
|
||||
max-height: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 240px;
|
||||
}
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
<!--
|
||||
Floating "island" action sheet: the app's confirm/notice modal.
|
||||
The app's confirm/notice action sheet (logout, place order, sign in, update,
|
||||
done).
|
||||
|
||||
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.
|
||||
content was poured into img/title/button slots).
|
||||
|
||||
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:
|
||||
RESTYLED into user-app-v2's own design language. It used to be a floating
|
||||
island inset from the screen edges, with a tinted dot-grid header, dashed
|
||||
rings, twinkling sparks, gradient buttons with a sheen and a per-sheet
|
||||
colour (navy / gold / green) — none of which appears anywhere else in this
|
||||
app. It now uses the same parts as the rest of v2:
|
||||
- a white sheet attached to the bottom edge, with the 32px top radius and
|
||||
grip of the product detail sheet and the search filter sheet;
|
||||
- the illustration centred on a borderless @hairlineSoft panel — the
|
||||
ProductPanel / catalogue-card treatment of a piece of jewellery;
|
||||
- a centred title and description under it, as on those cards;
|
||||
- the detail page's action bar: an outlined pill beside a filled pill.
|
||||
The filled pill is @ink, or @wine for the destructive tone (logout).
|
||||
The component name, props, events and illustrations are unchanged, so no
|
||||
call site had to change.
|
||||
|
||||
Usage:
|
||||
|
||||
<IslandSheet
|
||||
v-model:show="open"
|
||||
@@ -21,18 +33,18 @@
|
||||
/>
|
||||
|
||||
- `art` picks an illustration from SheetArt, and with it the sheet's tone
|
||||
(header wash + primary button); `tone` overrides it. An `#art` slot can
|
||||
(the filled button's colour); `tone` overrides it. An `#art` slot can
|
||||
replace the illustration entirely.
|
||||
- No `cancel-text` → a single-button sheet.
|
||||
- No `cancel-text` → a single, full-width button.
|
||||
- `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).
|
||||
- Performance: the illustration's own animations are held paused until the
|
||||
slide-in has finished (`is-ready`, set on Vant's `opened`), so the slide has
|
||||
the frame budget to itself; 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).
|
||||
-->
|
||||
@@ -50,11 +62,10 @@
|
||||
@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 }]"
|
||||
:class="[`iSheet--${resolvedTone}`, { 'is-dragging': dragging, 'is-ready': ready, 'is-single': !cancelText }]"
|
||||
:style="cardStyle"
|
||||
dir="rtl"
|
||||
role="dialog"
|
||||
@@ -64,11 +75,9 @@
|
||||
@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}`" />
|
||||
<span class="iSheet__grip" aria-hidden="true"></span>
|
||||
|
||||
<div class="iSheet__panel">
|
||||
<div class="iSheet__art">
|
||||
<slot name="art">
|
||||
<SheetArt v-if="art" :name="art" />
|
||||
@@ -79,13 +88,15 @@
|
||||
<h3 class="iSheet__title">{{ title }}</h3>
|
||||
<p v-if="description" class="iSheet__desc">{{ description }}</p>
|
||||
|
||||
<!-- The detail page's bar order: the secondary pill first in markup, so
|
||||
RTL lays it on the right, and the filled pill fills the rest. -->
|
||||
<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>
|
||||
<button type="button" class="iSheet__btn iSheet__btn--primary" @click="emit('confirm')">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
@@ -96,6 +107,8 @@ import { computed, ref } from 'vue'
|
||||
import { Popup } from 'vant'
|
||||
import SheetArt, { type SheetArtName } from './SheetArt.vue'
|
||||
|
||||
// Kept as the original five for API compatibility; v2 renders them as two
|
||||
// looks — destructive (wine) and everything else (ink). See the styles.
|
||||
type Tone = 'ink' | 'wine' | 'navy' | 'gold' | 'green'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -125,7 +138,6 @@ const artTone: Record<SheetArtName, Tone> = {
|
||||
update: 'navy',
|
||||
success: 'green',
|
||||
}
|
||||
|
||||
const resolvedTone = computed<Tone>(() => props.tone ?? (props.art ? artTone[props.art] : 'ink'))
|
||||
|
||||
const ready = ref(false)
|
||||
@@ -144,12 +156,6 @@ function 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).
|
||||
@@ -195,14 +201,14 @@ function onTouchEnd() {
|
||||
<style lang="less">
|
||||
// Global, not scoped: the popup is teleported to <body>.
|
||||
|
||||
// See-through dim, no blur — the same backdrop as the filter sheet.
|
||||
.iSheet__overlay {
|
||||
background: rgba(20, 22, 26, 0.48);
|
||||
background: rgba(20, 22, 26, 0.42);
|
||||
}
|
||||
|
||||
.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
|
||||
@@ -211,81 +217,47 @@ function onTouchEnd() {
|
||||
.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;
|
||||
// Hold the illustration's animations until the sheet has landed.
|
||||
.iSheet__card:not(.is-ready) *,
|
||||
.iSheet__card:not(.is-ready) *::after {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
|
||||
// Two looks, one per job: the filled button is @wine for the destructive
|
||||
// action (logout) and @ink — the app's primary surface — for everything else.
|
||||
.iSheet__card {
|
||||
--t: @ink;
|
||||
--t-on: @onInk;
|
||||
--t-rgb: 20, 22, 26;
|
||||
}
|
||||
.iSheet--wine {
|
||||
--t: @wine;
|
||||
--t-on: #fff;
|
||||
--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;
|
||||
}
|
||||
|
||||
// The detail page's / filter sheet's shell: white, attached to the bottom
|
||||
// edge, 32px top radius, a centred column on wide screens.
|
||||
.iSheet__card {
|
||||
position: relative;
|
||||
max-width: 440px;
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 8px 12px;
|
||||
border-radius: 30px;
|
||||
padding: 8px 16px calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 32px 32px 0 0;
|
||||
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);
|
||||
box-shadow: 0 -18px 40px -30px rgba(20, 22, 26, 0.55);
|
||||
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
touch-action: none;
|
||||
|
||||
@@ -294,84 +266,29 @@ function onTouchEnd() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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%;
|
||||
.iSheet__grip {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin-left: -19px;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--t-rgb), 0.18);
|
||||
z-index: 2;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: 999px;
|
||||
background: @hairline;
|
||||
}
|
||||
|
||||
.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; }
|
||||
// The ProductPanel treatment: the subject centred on a borderless soft-grey
|
||||
// panel at the catalogue cards' 32px radius (one step in, 26px, since it sits
|
||||
// inside the sheet's own 32px corner).
|
||||
.iSheet__panel {
|
||||
height: 150px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 26px;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.iSheet__art {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
|
||||
> img,
|
||||
> svg,
|
||||
@@ -383,18 +300,19 @@ function onTouchEnd() {
|
||||
}
|
||||
|
||||
// Every element sets its own font-size: style.less's `*` rule pins each one
|
||||
// to 14px, so nothing inherits.
|
||||
// to 14px, so nothing inherits. Sizes are the detail page's: 16px for the
|
||||
// heading (its section titles), 12.5px / line-height 2 for body copy.
|
||||
.iSheet__title {
|
||||
margin: 20px 16px 0;
|
||||
font-size: 16.5px;
|
||||
font-weight: 800;
|
||||
margin: 18px 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.75;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.iSheet__desc {
|
||||
margin: 6px 20px 0;
|
||||
font-size: 13px;
|
||||
margin: 6px 12px 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
line-height: 2;
|
||||
color: @inkMuted;
|
||||
@@ -402,9 +320,9 @@ function onTouchEnd() {
|
||||
|
||||
.iSheet__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 22px 4px 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
// Scoped under the card on purpose: Tailwind's preflight resets
|
||||
@@ -413,64 +331,44 @@ function onTouchEnd() {
|
||||
// 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;
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||||
transition: transform 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;
|
||||
}
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
// The detail page's outlined pill.
|
||||
&--ghost {
|
||||
color: @inkMuted;
|
||||
background: transparent;
|
||||
flex: 0 0 auto;
|
||||
min-width: 108px;
|
||||
padding: 0 22px;
|
||||
color: @ink;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
|
||||
&: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); }
|
||||
// The detail page's primary pill.
|
||||
&--primary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
color: var(--t-on);
|
||||
background: var(--t);
|
||||
box-shadow: 0 14px 30px -18px rgba(var(--t-rgb), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -478,7 +376,6 @@ function onTouchEnd() {
|
||||
.iSheet-slide-leave-active {
|
||||
transition-duration: 0.01s;
|
||||
}
|
||||
|
||||
.iSheet__card *,
|
||||
.iSheet__card *::after {
|
||||
animation: none !important;
|
||||
|
||||
@@ -74,18 +74,18 @@ const indicatorOffset = computed(
|
||||
// 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%);
|
||||
radial-gradient(120% 140% at 0% 0%, alpha(@canvas, 55%) 0%, alpha(@canvas, 0%) 55%),
|
||||
radial-gradient(90% 120% at 100% 100%, alpha(@wine, 7%) 0%, alpha(@wine, 0%) 60%),
|
||||
linear-gradient(160deg, alpha(@canvas, 42%) 0%, alpha(@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%),
|
||||
inset 0 1px 1px alpha(@canvas, 90%),
|
||||
inset 0 -8px 16px -10px alpha(@canvas, 60%),
|
||||
inset 0 0 0 0.5px alpha(@canvas, 35%),
|
||||
0 18px 40px -14px rgba(20, 22, 26, 0.32),
|
||||
0 6px 14px -8px fade(@wine, 22%),
|
||||
0 6px 14px -8px alpha(@wine, 22%),
|
||||
0 1px 3px rgba(20, 22, 26, 0.06);
|
||||
|
||||
// Curved specular gloss across the top, like light on a bent pane.
|
||||
@@ -95,7 +95,7 @@ const indicatorOffset = computed(
|
||||
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%);
|
||||
background: linear-gradient(180deg, alpha(@canvas, 60%) 0%, alpha(@canvas, 0%) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -109,10 +109,10 @@ const indicatorOffset = computed(
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
fade(@canvas, 100%) 0%,
|
||||
fade(@canvas, 35%) 35%,
|
||||
fade(@canvas, 10%) 65%,
|
||||
fade(@canvas, 70%) 100%
|
||||
alpha(@canvas, 100%) 0%,
|
||||
alpha(@canvas, 35%) 35%,
|
||||
alpha(@canvas, 10%) 65%,
|
||||
alpha(@canvas, 70%) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
@@ -126,7 +126,7 @@ const indicatorOffset = computed(
|
||||
// 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%);
|
||||
background: alpha(@canvas, 94%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ function onBack() {
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
background: alpha(@canvas, 88%);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid @hairline;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<span
|
||||
v-if="item.duration > 0"
|
||||
class="toast__progress"
|
||||
class="toast__progress keep-motion"
|
||||
:style="{ animationDuration: `${item.duration}ms` }"
|
||||
@animationend="emit('close')"
|
||||
/>
|
||||
@@ -132,14 +132,14 @@ function onUp() {
|
||||
|
||||
// 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%);
|
||||
radial-gradient(120% 160% at 0% 0%, alpha(@canvas, 60%) 0%, alpha(@canvas, 0%) 55%),
|
||||
radial-gradient(80% 140% at 100% 100%, alpha(@ink, 4%) 0%, alpha(@ink, 0%) 60%),
|
||||
linear-gradient(160deg, alpha(@canvas, 72%) 0%, alpha(@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%),
|
||||
inset 0 1px 1px alpha(@canvas, 90%),
|
||||
inset 0 0 0 0.5px alpha(@canvas, 40%),
|
||||
0 18px 40px -16px rgba(20, 22, 26, 0.35),
|
||||
0 1px 3px rgba(20, 22, 26, 0.06);
|
||||
|
||||
@@ -152,10 +152,10 @@ function onUp() {
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
fade(@canvas, 100%) 0%,
|
||||
fade(@canvas, 30%) 40%,
|
||||
fade(@canvas, 10%) 70%,
|
||||
fade(@canvas, 70%) 100%
|
||||
alpha(@canvas, 100%) 0%,
|
||||
alpha(@canvas, 30%) 40%,
|
||||
alpha(@canvas, 10%) 70%,
|
||||
alpha(@canvas, 70%) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
@@ -189,7 +189,7 @@ function onUp() {
|
||||
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.toast {
|
||||
background: fade(@canvas, 96%);
|
||||
background: alpha(@canvas, 96%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<!--
|
||||
The visual half of an on/off switch: a track and a knob. Deliberately not a
|
||||
control on its own — the row that holds it is the <button role="switch">,
|
||||
so the whole row is one tap target and screen readers announce one switch
|
||||
(see views/user/AppearanceView.vue).
|
||||
|
||||
STRUCTURE IS SHARED, LOOK IS NOT: identical markup and script in user-app
|
||||
and user-app-v2; only the "Look" block differs, per app.
|
||||
-->
|
||||
<template>
|
||||
<span class="tsw" :class="{ 'is-on': on }" aria-hidden="true">
|
||||
<span class="tsw__knob"></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ on: boolean }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* ── Mechanics (same in both apps) ──────────────────────────── */
|
||||
.tsw {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
transition: background-color 0.22s ease;
|
||||
}
|
||||
|
||||
.tsw__knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
// RTL page: "off" sits on the right (start), "on" slides to the left (end).
|
||||
inset-inline-start: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px -1px rgba(20, 22, 26, 0.35);
|
||||
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1), background-color 0.22s ease;
|
||||
}
|
||||
|
||||
.tsw.is-on .tsw__knob {
|
||||
transform: translateX(-18px);
|
||||
}
|
||||
|
||||
/* ── Look: user-app-v2 ──────────────────────────────────────── */
|
||||
// The app's chosen state is a filled @ink (chips, CTAs): the track fills @ink
|
||||
// and the knob takes @onInk, so it stays a contrasting pair in both themes.
|
||||
.tsw {
|
||||
background: @hairline;
|
||||
}
|
||||
.tsw__knob {
|
||||
background: #fff;
|
||||
}
|
||||
.tsw.is-on {
|
||||
background: @ink;
|
||||
}
|
||||
.tsw.is-on .tsw__knob {
|
||||
background: @onInk;
|
||||
}
|
||||
</style>
|
||||
@@ -128,11 +128,11 @@ async function onPick(event: Event) {
|
||||
}
|
||||
|
||||
.is-done .siu__box {
|
||||
border-bottom-color: #16a34a;
|
||||
border-bottom-color: @green;
|
||||
}
|
||||
|
||||
.is-error .siu__box {
|
||||
border-bottom-color: #dc2626;
|
||||
border-bottom-color: @red;
|
||||
}
|
||||
|
||||
.siu__preview {
|
||||
@@ -167,12 +167,12 @@ async function onPick(event: Event) {
|
||||
.siu__badge {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
color: #16a34a;
|
||||
color: @green;
|
||||
}
|
||||
|
||||
.siu__error {
|
||||
margin: 7px 2px 0;
|
||||
font-size: 12px;
|
||||
color: #dc2626;
|
||||
color: @red;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user