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:
@@ -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>
|
||||
Reference in New Issue
Block a user