Files
tehran_tala/apps/user-app-v2/src/components/ui/DropDown.vue
T
s.esmaeili f441ca8289 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.
2026-10-04 16:18:56 +03:30

163 lines
4.8 KiB
Vue

<!--
Ported from legacy henza_pwa/src/components/dropDown.vue, restyled into the
redesigned design language (see components/home/* and HomeView) — previously
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" :aria-expanded="show" @click="show = !show">
<h3 class="dd__title">
<slot name="text">{{ text }}</slot>
</h3>
<span class="dd__chevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-down" size="16" />
</span>
</button>
<div class="dd__panel" :inert="!show">
<div class="dd__clip">
<div class="dd__body">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
const props = withDefaults(defineProps<{ text?: string; defaultOpen?: boolean }>(), { defaultOpen: false })
const show = ref(props.defaultOpen)
</script>
<style scoped lang="less">
/* ── 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;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
border: 0;
cursor: pointer;
background: none;
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
}
.dd__title {
margin: 0;
}
.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 {
width: 28px;
height: 28px;
border-radius: 9px;
color: @inkMuted;
background: @canvas;
}
.dd.is-open .dd__chevron {
color: @onInk;
background: @ink;
}
.dd__body {
padding: 0 16px 16px;
}
</style>