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:
@@ -17,7 +17,7 @@
|
||||
<div class="w-full flex justify-center text-[18px] font-semibold">
|
||||
<slot name="title">{{ text1 }}</slot>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 border-t border-solid border-[#E5E7EB] pt-4 mx-2 mt-6 font-semibold text-[15px]">
|
||||
<div class="grid grid-cols-2 border-t border-solid border-[color:var(--c-hairline)] pt-4 mx-2 mt-6 font-semibold text-[15px]">
|
||||
<slot name="button">{{ text3 }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,12 +44,12 @@ withDefaults(
|
||||
display: none !important;
|
||||
}
|
||||
.van-dialog__confirm .van-button__text {
|
||||
color: #23493c;
|
||||
color: @primary;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.van-dialog__cancel .van-button__text {
|
||||
color: #6b7280;
|
||||
color: @inkMuted;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="w-full flex justify-center text-[18px] font-semibold">
|
||||
<slot name="title">{{ text1 }}</slot>
|
||||
</div>
|
||||
<div class="border-t border-solid border-[#E5E7EB] pt-4 mx-2 mt-6 font-semibold text-[15px]">
|
||||
<div class="border-t border-solid border-[color:var(--c-hairline)] pt-4 mx-2 mt-6 font-semibold text-[15px]">
|
||||
<slot name="button">{{ text3 }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,12 +44,12 @@ withDefaults(
|
||||
display: none !important;
|
||||
}
|
||||
.van-dialog__confirm .van-button__text {
|
||||
color: #23493c;
|
||||
color: @primary;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.van-dialog__cancel .van-button__text {
|
||||
color: #6b7280;
|
||||
color: @inkMuted;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -88,6 +88,14 @@ const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
// 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 .rail__mark img {
|
||||
background: @photoMat;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.rail__title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -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)">
|
||||
@@ -84,6 +84,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 .card__art img {
|
||||
background: @photoMat;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card__fav {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
@@ -96,7 +104,7 @@ const wageLabel = computed(() => {
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: @inkMuted;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: alpha(@canvas, 90%);
|
||||
backdrop-filter: blur(6px);
|
||||
transition: color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useAppearanceStore } from '../../stores/appearanceStore'
|
||||
|
||||
export interface ShowcaseSlide {
|
||||
key: string
|
||||
@@ -70,6 +71,7 @@ const emit = defineEmits<{ (e: 'select', slide: ShowcaseSlide): void }>()
|
||||
|
||||
const index = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
const appearance = useAppearanceStore()
|
||||
|
||||
function go(i: number) {
|
||||
index.value = i
|
||||
@@ -77,7 +79,9 @@ function go(i: number) {
|
||||
}
|
||||
function restart() {
|
||||
if (timer) clearInterval(timer)
|
||||
if (props.slides.length > 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.slides.length > 1 && !appearance.isReducedMotion) {
|
||||
timer = setInterval(() => {
|
||||
index.value = (index.value + 1) % props.slides.length
|
||||
}, 6000)
|
||||
|
||||
@@ -129,6 +129,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%;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
stored on the product.
|
||||
-->
|
||||
<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">
|
||||
@@ -49,25 +49,13 @@
|
||||
</div>
|
||||
</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 { computed, 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'
|
||||
|
||||
@@ -76,9 +64,10 @@ const props = defineProps<{ value: ProductSummary }>()
|
||||
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)
|
||||
}
|
||||
|
||||
const wageLabel = computed(() => {
|
||||
@@ -121,6 +110,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 .pc__art img {
|
||||
background: @photoMat;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pc__artEmpty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -141,7 +138,7 @@ const wageLabel = computed(() => {
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: @inkMuted;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: alpha(@canvas, 90%);
|
||||
backdrop-filter: blur(6px);
|
||||
transition: color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -300,20 +300,22 @@ import { toast } from '../../composables/useToast'
|
||||
import DialogVant from '../DialogVant.vue'
|
||||
import { useProduct, isVariantOrderable } from '../../composables/useProduct'
|
||||
import { useProductDetail } from '../../composables/useProductDetail'
|
||||
import type { ProductVariantSummary } from '../../api/client'
|
||||
import type { ProductSummary, ProductVariantSummary } from '../../api/client'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { useCartStore, type CartItem } from '../../stores/cartStore'
|
||||
import { isUserLoggedIn } from '../../utils/checkAccess'
|
||||
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
// `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()
|
||||
const favorites = useFavoritesStore()
|
||||
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().
|
||||
@@ -326,7 +328,7 @@ const loginNotify = ref(false)
|
||||
const imageIndex = ref(0)
|
||||
const bodyOpen = ref(false)
|
||||
|
||||
const productDetail = useProductDetail(props.id)
|
||||
const productDetail = useProductDetail(props.id, props.initial)
|
||||
const loading = productDetail.loading
|
||||
const flag = productDetail.flag
|
||||
|
||||
@@ -515,7 +517,7 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-height: 52vh;
|
||||
background: @hairlineSoft;
|
||||
background: @photoStage;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -544,7 +546,7 @@ onBeforeUnmount(() => {
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: @inkSoft;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
background: alpha(@canvas, 92%);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 6px 18px -10px rgba(20, 22, 26, 0.5);
|
||||
transition: transform 0.18s ease, color 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-v2's Filters.vue; each app styles the same markup in
|
||||
its own design language (this one: soft-grey sheet with the 22px top radius
|
||||
the detail page's sheet uses, white hairline-bordered cards via DropDown,
|
||||
@primary 14px-radius buttons and pill chips, round controls).
|
||||
|
||||
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,404 @@ 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: @primary;
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.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 ─────────────────────────────────────────── */
|
||||
// The detail page's sheet: a soft-grey ground with the 22px top radius, so the
|
||||
// white bordered filter cards stand off it the way the page's cards do.
|
||||
.fSheet__card {
|
||||
border-radius: 22px 22px 0 0;
|
||||
background: @canvasSoft;
|
||||
box-shadow: 0 -12px 34px -22px rgba(20, 22, 26, 0.45);
|
||||
}
|
||||
|
||||
.fSheet__head {
|
||||
padding: 8px 16px 12px;
|
||||
}
|
||||
|
||||
.fSheet__grip {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 999px;
|
||||
background: @lightGray2;
|
||||
}
|
||||
|
||||
.fSheet__title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
|
||||
.fSheet__count {
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
// The detail page's round floating controls.
|
||||
.fSheet__close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: @inkSoft;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.fSheet__body {
|
||||
gap: 12px;
|
||||
padding: 2px 16px 16px;
|
||||
}
|
||||
|
||||
// The search page's chips.
|
||||
.fSheet__chip {
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: @inkSoft;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
}
|
||||
.fSheet__chip.is-on {
|
||||
color: #fff;
|
||||
background: @primary;
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
.fSheet__gram {
|
||||
padding: 2px 6px 0;
|
||||
}
|
||||
.fSheet__gram .ant-slider-rail {
|
||||
background-color: @hairline;
|
||||
}
|
||||
.fSheet__gram .ant-slider-track {
|
||||
background-color: @primary;
|
||||
}
|
||||
.fSheet__gram .ant-slider-handle {
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
.fSheet__gramFigures {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.fSheet__gramFigure {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.filt__gramFigure i {
|
||||
font-style: normal;
|
||||
.fSheet__gramFigure i {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: @inkFaint;
|
||||
padding-inline-end: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
.fSheet__gramFigure b {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.filt__barInner {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
// The detail page's action bar: white, hairline on top, 14px-radius buttons.
|
||||
.fSheet__bar {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
background: @canvas;
|
||||
border-top: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.filt__clear,
|
||||
.filt__apply {
|
||||
flex: 1;
|
||||
padding: 13px 18px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
.fSheet__clear,
|
||||
.fSheet__apply {
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: transform 0.15s ease;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.filt__clear {
|
||||
color: @inkSoft;
|
||||
background: @hairlineSoft;
|
||||
.fSheet__clear {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 20px;
|
||||
color: @ink;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.filt__apply {
|
||||
.fSheet__apply {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: @primary;
|
||||
}
|
||||
|
||||
.filt__clear:active,
|
||||
.filt__apply:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</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,95 @@ 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;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.dd__chevron {
|
||||
flex: 0 0 auto;
|
||||
color: @inkFaint;
|
||||
transition: transform 0.2s ease;
|
||||
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);
|
||||
color: @wine;
|
||||
}
|
||||
|
||||
.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 ─────────────────────────────────────────── */
|
||||
// A white card with a hairline border and the 16px radius the detail page's
|
||||
// spec table uses; the border and chevron take the brand green when open.
|
||||
.dd {
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 16px;
|
||||
background: @canvas;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.dd.is-open {
|
||||
border-color: alpha(@primary, 28%);
|
||||
}
|
||||
|
||||
.dd__head {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.dd__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.dd__chevron {
|
||||
color: @inkFaint;
|
||||
}
|
||||
.dd.is-open .dd__chevron {
|
||||
color: @primary;
|
||||
}
|
||||
|
||||
.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 15px 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,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,59 @@
|
||||
<!--
|
||||
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 ─────────────────────────────────────────── */
|
||||
// The app's chosen state is brand green (@primary, as on chips and CTAs).
|
||||
.tsw {
|
||||
background: @lightGray1;
|
||||
}
|
||||
.tsw__knob {
|
||||
background: #fff;
|
||||
}
|
||||
.tsw.is-on {
|
||||
background: @primary;
|
||||
}
|
||||
</style>
|
||||
@@ -121,11 +121,11 @@ async function onPick(event: Event) {
|
||||
|
||||
.is-done .siu__box {
|
||||
border-style: solid;
|
||||
border-color: #16a34a;
|
||||
border-color: @green;
|
||||
}
|
||||
|
||||
.is-error .siu__box {
|
||||
border-color: #dc2626;
|
||||
border-color: @red;
|
||||
}
|
||||
|
||||
.siu__preview {
|
||||
@@ -155,12 +155,12 @@ async function onPick(event: Event) {
|
||||
left: 10px;
|
||||
z-index: 1;
|
||||
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