@shopware/cms-base-layer 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +168 -100
  2. package/app/app.config.ts +11 -0
  3. package/app/components/SwCategoryNavigation.vue +25 -18
  4. package/app/components/SwFilterDropdown.vue +54 -0
  5. package/app/components/SwListingProductPrice.vue +2 -2
  6. package/app/components/SwMedia3D.vue +4 -2
  7. package/app/components/SwProductCard.vue +20 -21
  8. package/app/components/SwProductCardDetails.vue +29 -12
  9. package/app/components/SwProductCardImage.vue +4 -1
  10. package/app/components/SwProductGallery.vue +18 -14
  11. package/app/components/SwProductListingFilter.vue +20 -9
  12. package/app/components/SwProductListingFilters.vue +1 -5
  13. package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
  14. package/app/components/SwProductPrice.vue +3 -3
  15. package/app/components/SwProductRating.vue +40 -0
  16. package/app/components/SwProductReviews.vue +6 -19
  17. package/app/components/SwProductUnits.vue +10 -15
  18. package/app/components/SwQuantitySelect.vue +4 -7
  19. package/app/components/SwSlider.vue +150 -51
  20. package/app/components/SwSortDropdown.vue +10 -6
  21. package/app/components/SwVariantConfigurator.vue +12 -11
  22. package/app/components/listing-filters/SwFilterPrice.vue +45 -40
  23. package/app/components/listing-filters/SwFilterProperties.vue +40 -33
  24. package/app/components/listing-filters/SwFilterRating.vue +36 -27
  25. package/app/components/listing-filters/SwFilterShippingFree.vue +39 -32
  26. package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
  27. package/app/components/public/cms/CmsGenericBlock.md +17 -2
  28. package/app/components/public/cms/CmsGenericBlock.vue +15 -1
  29. package/app/components/public/cms/CmsPage.md +19 -2
  30. package/app/components/public/cms/CmsPage.vue +11 -1
  31. package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
  32. package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
  33. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
  34. package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
  35. package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
  36. package/app/components/public/cms/element/CmsElementImage.vue +34 -36
  37. package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
  38. package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
  39. package/app/components/public/cms/element/CmsElementProductListing.vue +10 -3
  40. package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
  41. package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
  42. package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
  43. package/app/components/public/cms/element/CmsElementText.vue +10 -11
  44. package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
  45. package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
  46. package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
  47. package/app/components/ui/BaseButton.vue +18 -15
  48. package/app/components/ui/ChevronIcon.vue +10 -13
  49. package/app/components/ui/WishlistIcon.vue +3 -8
  50. package/app/composables/useImagePlaceholder.ts +1 -1
  51. package/app/composables/useLcpImagePreload.test.ts +229 -0
  52. package/app/composables/useLcpImagePreload.ts +39 -0
  53. package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
  54. package/app/helpers/cms/getImageSizes.test.ts +50 -0
  55. package/app/helpers/cms/getImageSizes.ts +36 -0
  56. package/app/helpers/html-to-vue/ast.ts +53 -19
  57. package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
  58. package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
  59. package/app/helpers/html-to-vue/renderer.ts +86 -26
  60. package/app/plugins/unocss-runtime.client.ts +23 -0
  61. package/index.d.ts +24 -0
  62. package/nuxt.config.ts +20 -0
  63. package/package.json +23 -21
  64. package/uno.config.ts +11 -0
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { defu } from "defu";
3
- import { computed, getCurrentInstance } from "vue";
3
+ import { computed, useId } from "vue";
4
4
  import { useCmsTranslations } from "#imports";
5
5
 
6
6
  type Translations = {
@@ -43,12 +43,9 @@ const {
43
43
  id?: string;
44
44
  }>();
45
45
 
46
- // generate an id that prefers a provided prop and otherwise uses the component uid
47
- const inputId = computed(() => {
48
- if (propId) return propId;
49
- const uid = Math.random().toString(36).substr(2, 9);
50
- return `sw-quantity-${uid}`;
51
- });
46
+ // generate an id that prefers a provided prop and otherwise uses Vue's useId for SSR/CSR consistency
47
+ const generatedId = useId();
48
+ const inputId = computed(() => propId || generatedId);
52
49
 
53
50
  function increaseQty() {
54
51
  quantity.value++;
@@ -10,30 +10,36 @@ import {
10
10
  useTemplateRef,
11
11
  watch,
12
12
  } from "vue";
13
- import type { CSSProperties, VNodeArrayChildren } from "vue";
14
- import { useCmsElementConfig } from "#imports";
13
+ import type { CSSProperties, VNode, VNodeArrayChildren } from "vue";
14
+ import { useCmsElementConfig, useHead, useId } from "#imports";
15
15
  import type { Schemas } from "#shopware";
16
16
 
17
- const props = withDefaults(
18
- defineProps<{
19
- config: SliderElementConfig;
20
- slidesToShow?: number;
21
- slidesToScroll?: number;
22
- gap?: string;
23
- autoplay?: boolean;
24
- autoplaySpeed?: number;
25
- }>(),
26
- {
27
- slidesToShow: 1,
28
- slidesToScroll: 1,
29
- gap: "0px",
30
- autoplay: false,
31
- autoplaySpeed: 3000,
32
- },
33
- );
17
+ const {
18
+ config,
19
+ slidesToShow: slidesToShowProp = 1,
20
+ slidesToScroll: slidesToScrollProp = 1,
21
+ gap = "0px",
22
+ autoplay = false,
23
+ autoplaySpeed = 3000,
24
+ ssrBreakpoints,
25
+ } = defineProps<{
26
+ config: SliderElementConfig;
27
+ slidesToShow?: number;
28
+ slidesToScroll?: number;
29
+ gap?: string;
30
+ autoplay?: boolean;
31
+ autoplaySpeed?: number;
32
+ /** CSS media query breakpoints for responsive SSR layout.
33
+ * Keys are media queries, values are number of visible slides.
34
+ * e.g. { '(min-width: 768px)': 2, '(min-width: 1280px)': 4 }
35
+ * Base case (mobile) defaults to 1 visible slide. */
36
+ ssrBreakpoints?: Record<string, number>;
37
+ }>();
38
+
39
+ const sliderId = useId();
34
40
 
35
41
  const { getConfigValue } = useCmsElementConfig({
36
- config: props.config,
42
+ config: config,
37
43
  } as Omit<Schemas["CmsSlot"], "config"> & {
38
44
  config: SliderElementConfig;
39
45
  });
@@ -41,27 +47,39 @@ const { getConfigValue } = useCmsElementConfig({
41
47
  const slots = useSlots() as {
42
48
  default?: () => { children: VNodeArrayChildren }[];
43
49
  };
44
- const childrenRaw = computed(
45
- () => (slots?.default?.()[0]?.children as VNodeArrayChildren) ?? [],
46
- );
50
+
51
+ // get fresh children from slot - call this each time to get new VNode instances
52
+ function getSlotChildren(): VNode[] {
53
+ return (slots?.default?.()[0]?.children as VNode[]) ?? [];
54
+ }
55
+
56
+ const childrenRaw = computed(() => getSlotChildren());
57
+
47
58
  const slidesToScroll = computed(() =>
48
- props.slidesToScroll >= props.slidesToShow
49
- ? props.slidesToShow
50
- : props.slidesToScroll,
59
+ slidesToScrollProp >= slidesToShowProp
60
+ ? slidesToShowProp
61
+ : slidesToScrollProp,
51
62
  );
52
63
  const slidesToShow = computed(() =>
53
- props.slidesToShow >= childrenRaw.value.length
64
+ slidesToShowProp >= childrenRaw.value.length
54
65
  ? childrenRaw.value.length
55
- : props.slidesToShow,
66
+ : slidesToShowProp,
56
67
  );
57
- const children = computed<string[]>(() => {
58
- if (childrenRaw.value.length === 0) return [];
68
+
69
+ // build children array with fresh VNodes for infinite scroll
70
+ // we must call getSlotChildren() separately for each section because Vue can only render each VNode once
71
+ const children = computed<VNode[]>(() => {
72
+ const count = childrenRaw.value.length;
73
+ if (count === 0) return [];
74
+
75
+ const n = slidesToShow.value;
59
76
  return [
60
- ...childrenRaw.value.slice(-slidesToShow.value),
61
- ...childrenRaw.value,
62
- ...childrenRaw.value.slice(0, slidesToShow.value),
63
- ] as string[];
77
+ ...getSlotChildren().slice(-n), // prepend: last N slides
78
+ ...getSlotChildren(), // main slides
79
+ ...getSlotChildren().slice(0, n), // append: first N slides
80
+ ];
64
81
  });
82
+
65
83
  const emit = defineEmits<(e: "changeSlide", index: number) => void>();
66
84
  const slider = useTemplateRef<HTMLDivElement>("slider");
67
85
  const imageSlider = useTemplateRef<HTMLDivElement>("imageSlider");
@@ -76,6 +94,79 @@ const isSliding = ref<boolean>();
76
94
  const { width: imageSliderWidth } = useElementSize(imageSlider);
77
95
  let timeoutGuard: ReturnType<typeof setTimeout> | undefined;
78
96
 
97
+ // SSR-safe fallback so the first slide is visible before JS hydrates
98
+ const ssrTrackStyle = computed<CSSProperties>(() => {
99
+ const total = children.value.length;
100
+ const n = slidesToShow.value;
101
+ if (total === 0 || n === 0) return {};
102
+
103
+ // Transform is constant: always skip N prepended clones
104
+ const transform = `translateX(-${(n / total) * 100}%)`;
105
+
106
+ if (ssrBreakpoints) {
107
+ // Both width and transform handled by CSS media queries via useHead
108
+ return {};
109
+ }
110
+
111
+ return {
112
+ width: `${(total / n) * 100}%`,
113
+ transform,
114
+ };
115
+ });
116
+
117
+ // Inject responsive CSS into <head> for SSR breakpoints.
118
+ // Transform is constant: always skip N prepended clones (`n / total`),
119
+ // because translateX(%) is relative to the element's own width which scales
120
+ // proportionally with the number of visible slides. Only width varies per breakpoint.
121
+ // Removed entirely once client-side JS sets imageSliderTrackStyle.
122
+ useHead(
123
+ computed(() => {
124
+ if (!ssrBreakpoints || imageSliderTrackStyle.value) return {};
125
+ const total = children.value.length;
126
+ const n = slidesToShow.value;
127
+ if (total === 0 || n === 0) return {};
128
+
129
+ const sel = `[data-ssr-slider="${sliderId}"]`;
130
+ const tx = `translateX(-${(n / total) * 100}%)`;
131
+
132
+ // Mobile base: 1 slide visible
133
+ let css = `${sel}{width:${total * 100}%;transform:${tx}}`;
134
+ // Breakpoint overrides — only width changes
135
+ for (const [query, slides] of Object.entries(ssrBreakpoints)) {
136
+ css += `@media ${query}{${sel}{width:${(total / slides) * 100}%}}`;
137
+ }
138
+ return { style: [{ innerHTML: css }] };
139
+ }),
140
+ );
141
+
142
+ // Touch event handling for mobile swipe gestures
143
+ const touchStartX = ref(0);
144
+ const touchEndX = ref(0);
145
+
146
+ function onTouchStart(event: TouchEvent) {
147
+ touchStartX.value = event.touches?.[0]?.clientX || 0;
148
+ }
149
+
150
+ function onTouchMove(event: TouchEvent) {
151
+ touchEndX.value = event.touches?.[0]?.clientX || 0;
152
+ }
153
+
154
+ function onTouchEnd() {
155
+ const deltaX = touchEndX.value - touchStartX.value;
156
+ const threshold = 50; // pixels
157
+
158
+ if (Math.abs(deltaX) > threshold) {
159
+ if (deltaX < 0) {
160
+ next();
161
+ } else {
162
+ previous();
163
+ }
164
+ }
165
+
166
+ touchStartX.value = 0;
167
+ touchEndX.value = 0;
168
+ }
169
+
79
170
  onMounted(() => {
80
171
  initSlider();
81
172
 
@@ -92,12 +183,12 @@ onBeforeUnmount(() => {
92
183
  });
93
184
 
94
185
  watch(
95
- () => props.autoplay && isReady.value,
186
+ () => autoplay && isReady.value,
96
187
  (value) => {
97
188
  if (value) {
98
189
  autoPlayInterval.value = setInterval(() => {
99
190
  next();
100
- }, props.autoplaySpeed);
191
+ }, autoplaySpeed);
101
192
  } else {
102
193
  if (autoPlayInterval.value) {
103
194
  clearInterval(autoPlayInterval.value);
@@ -112,8 +203,8 @@ watch(
112
203
  const imageSliderStyle = computed(() => {
113
204
  if (getConfigValue("displayMode") === "cover") {
114
205
  return {
115
- height: getConfigValue("minHeight"),
116
- margin: `0 -${props.gap}`,
206
+ minHeight: getConfigValue("minHeight"),
207
+ margin: `0 -${gap}`,
117
208
  };
118
209
  }
119
210
  return {
@@ -129,10 +220,10 @@ const displayModeValue = computed(
129
220
  );
130
221
 
131
222
  const navigationArrowsValue = computed(
132
- () => props.config?.navigationArrows?.value || "none",
223
+ () => getConfigValue("navigationArrows") || "none",
133
224
  );
134
225
  const navigationDotsValue = computed(
135
- () => props.config?.navigationDots?.value || "none",
226
+ () => getConfigValue("navigationDots") || "none",
136
227
  );
137
228
 
138
229
  function initSlider() {
@@ -246,16 +337,19 @@ defineExpose({
246
337
  'relative overflow-hidden h-full': true,
247
338
  'px-10': navigationArrowsValue === 'outside',
248
339
  'pb-15': navigationDotsValue === 'outside',
249
- 'opacity-0': !isReady,
250
340
  }"
251
341
  >
252
342
  <div
253
343
  ref="imageSlider"
254
344
  class="overflow-hidden h-full"
255
345
  :style="imageSliderStyle"
346
+ @touchstart="onTouchStart"
347
+ @touchmove="onTouchMove"
348
+ @touchend="onTouchEnd"
256
349
  >
257
350
  <div
258
351
  ref="imageSliderTrack"
352
+ :data-ssr-slider="ssrBreakpoints ? sliderId : undefined"
259
353
  :class="{
260
354
  flex: true,
261
355
  'items-center':
@@ -266,7 +360,7 @@ defineExpose({
266
360
  'items-end':
267
361
  displayModeValue === 'contain' && verticalAlignValue === 'flex-end',
268
362
  }"
269
- :style="imageSliderTrackStyle"
363
+ :style="imageSliderTrackStyle || ssrTrackStyle"
270
364
  >
271
365
  <div
272
366
  v-for="(child, index) of children"
@@ -275,7 +369,7 @@ defineExpose({
275
369
  :style="{
276
370
  width: imageSliderWidth
277
371
  ? `${imageSliderWidth / slidesToShow}px`
278
- : 'auto',
372
+ : `${100 / children.length}%`,
279
373
  padding: `0 ${gap}`,
280
374
  height: displayModeValue === 'standard' ? 'min-content' : '100%',
281
375
  }"
@@ -288,29 +382,33 @@ defineExpose({
288
382
  <button
289
383
  aria-label="Previous slide"
290
384
  :class="{
291
- 'absolute bg-transparent top-1/2 left-0 transform -translate-y-1/2 py-4': true,
385
+ 'absolute top-1/2 left-4 transform -translate-y-1/2 w-10 h-10 rounded-full flex items-center justify-center': true,
386
+ 'bg-brand-tertiary text-surface-on-surface':
387
+ navigationArrowsValue === 'outside',
292
388
  'transition bg-white/20 hover:bg-white/50':
293
389
  navigationArrowsValue === 'inside',
294
390
  }"
295
391
  @click="previous"
296
392
  >
297
- <div class="w-15 h-15 i-carbon-chevron-left"></div>
393
+ <SwChevronIcon direction="left" />
298
394
  </button>
299
395
  <button
300
396
  aria-label="Next slide"
301
397
  :class="{
302
- 'absolute bg-transparent top-1/2 right-0 transform -translate-y-1/2 py-4': true,
398
+ 'absolute top-1/2 right-4 transform -translate-y-1/2 w-10 h-10 rounded-full flex items-center justify-center': true,
399
+ 'bg-brand-tertiary text-surface-on-surface':
400
+ navigationArrowsValue === 'outside',
303
401
  'transition bg-white/20 hover:bg-white/50':
304
402
  navigationArrowsValue === 'inside',
305
403
  }"
306
404
  @click="next"
307
405
  >
308
- <div class="w-15 h-15 i-carbon-chevron-right"></div>
406
+ <SwChevronIcon direction="right" />
309
407
  </button>
310
408
  </div>
311
409
  <div
312
410
  :class="{
313
- 'absolute bottom-5 left-1/2 transform -translate-x-1/2 gap-5': true,
411
+ 'absolute bottom-5 left-1/2 transform -translate-x-1/2 gap-2 items-center': true,
314
412
  flex: navigationDotsValue !== 'none',
315
413
  hidden: navigationDotsValue === 'none',
316
414
  }"
@@ -319,9 +417,10 @@ defineExpose({
319
417
  v-for="(_, i) of childrenRaw"
320
418
  :key="`dot-${i}`"
321
419
  :class="{
322
- 'w-5 h-5 rounded-full cursor-pointer': true,
323
- 'bg-gray-100': i === activeSlideIndex,
324
- 'bg-gray-500/50': i !== activeSlideIndex,
420
+ 'rounded-full cursor-pointer transition-all duration-300': true,
421
+ 'w-6 h-2 bg-surface-on-surface-variant': i === activeSlideIndex,
422
+ 'w-2 h-2 bg-surface-surface-container-highest':
423
+ i !== activeSlideIndex,
325
424
  }"
326
425
  @click="() => goToSlide(i)"
327
426
  ></div>
@@ -4,7 +4,10 @@ import { ref, useTemplateRef } from "vue";
4
4
 
5
5
  type SortOption = {
6
6
  key: string;
7
- label: string;
7
+ label: string | null;
8
+ translated?: {
9
+ label: string;
10
+ };
8
11
  };
9
12
 
10
13
  defineProps<{
@@ -39,7 +42,7 @@ const handleSortingClick = (key: string) => {
39
42
  type="button"
40
43
  @click="isSortMenuOpen = !isSortMenuOpen"
41
44
  id="menu-button"
42
- aria-expanded="false"
45
+ :aria-expanded="isSortMenuOpen"
43
46
  aria-haspopup="true"
44
47
  class="group pr-0"
45
48
  >
@@ -48,13 +51,14 @@ const handleSortingClick = (key: string) => {
48
51
  <SwChevronIcon
49
52
  :direction="isSortMenuOpen ? 'up' : 'down'"
50
53
  :size="24"
51
- :aria-label="isSortMenuOpen ? 'Close sort menu' : 'Open sort menu'"
54
+ aria-hidden="true"
55
+ focusable="false"
52
56
  />
53
57
  </span>
54
58
  </SwBaseButton>
55
59
  <div
56
60
  :class="[isSortMenuOpen ? 'absolute' : 'hidden']"
57
- class="origin-top-right right-0 mt-2 w-40 rounded-md shadow-2xl bg-surface-surface ring-1 ring-opacity-dark-low focus:outline-none z-1000"
61
+ class="origin-top-right right-0 mt-2 w-40 rounded-md shadow-2xl bg-surface-surface ring-1 ring-outline-outline-variant focus:outline-none z-50"
58
62
  role="menu"
59
63
  aria-orientation="vertical"
60
64
  aria-labelledby="menu-button"
@@ -70,11 +74,11 @@ const handleSortingClick = (key: string) => {
70
74
  ? 'font-medium text-surface-on-surface'
71
75
  : 'text-surface-on-surface-variant',
72
76
  ]"
73
- class="block px-4 py-2 text-sm bg-transparent hover:bg-surface-surface-container"
77
+ class="block w-full text-left px-4 py-2 text-sm bg-transparent hover:bg-surface-surface-container"
74
78
  role="menuitem"
75
79
  tabindex="-1"
76
80
  >
77
- {{ sorting.label }}
81
+ {{ sorting.translated?.label }}
78
82
  </button>
79
83
  </div>
80
84
  </div>
@@ -12,14 +12,9 @@ const { getUrlPrefix } = useUrlResolver();
12
12
 
13
13
  const prefix = getUrlPrefix();
14
14
 
15
- const props = withDefaults(
16
- defineProps<{
17
- allowRedirect?: boolean;
18
- }>(),
19
- {
20
- allowRedirect: true,
21
- },
22
- );
15
+ const { allowRedirect = true } = defineProps<{
16
+ allowRedirect?: boolean;
17
+ }>();
23
18
 
24
19
  type Translations = {
25
20
  product: {
@@ -61,7 +56,7 @@ const onHandleChange = async () => {
61
56
  getProductRoute(variantFound),
62
57
  prefix,
63
58
  );
64
- if (props.allowRedirect && selectedOptionsVariantPath) {
59
+ if (allowRedirect && selectedOptionsVariantPath) {
65
60
  try {
66
61
  router.push(selectedOptionsVariantPath);
67
62
  } catch {
@@ -90,7 +85,7 @@ const onHandleChange = async () => {
90
85
  :key="optionGroup.id"
91
86
  class="mt-6"
92
87
  >
93
- <h3 class="text-sm text-gray-900 font-medium">{{ optionGroup.name }}</h3>
88
+ <div class="text-sm text-gray-900 font-medium">{{ optionGroup.name }}</div>
94
89
  <fieldset class="mt-4 flex-1">
95
90
  <legend class="sr-only">
96
91
  {{ translations.product.chooseA }} {{ optionGroup.name }}
@@ -104,7 +99,13 @@ const onHandleChange = async () => {
104
99
  :class="{
105
100
  'border-3 border-brand-primary': isOptionSelected(option.id),
106
101
  }"
107
- @click="handleChange(optionGroup.translated.name, option.id, onHandleChange)"
102
+ @click="
103
+ handleChange(
104
+ optionGroup.translated.name,
105
+ option.id,
106
+ onHandleChange,
107
+ )
108
+ "
108
109
  >
109
110
  <p
110
111
  :id="`${option.id}-choice-label`"
@@ -17,9 +17,14 @@ const emits =
17
17
  (e: "select-value", value: { code: string; value: unknown }) => void
18
18
  >();
19
19
 
20
- const props = defineProps<{
20
+ const {
21
+ filter,
22
+ selectedFilters,
23
+ displayMode = "accordion",
24
+ } = defineProps<{
21
25
  filter: ListingFilter;
22
26
  selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
27
+ displayMode?: "accordion" | "dropdown";
23
28
  }>();
24
29
 
25
30
  type Translations = {
@@ -42,12 +47,8 @@ const prices = reactive<{ min: number; max: number }>({
42
47
  });
43
48
 
44
49
  onMounted(() => {
45
- prices.min = Math.floor(
46
- props.selectedFilters.price?.min ?? props.filter.min ?? 0,
47
- );
48
- prices.max = Math.floor(
49
- props.selectedFilters.price?.max ?? props.filter.max ?? 0,
50
- );
50
+ prices.min = Math.floor(selectedFilters.price?.min ?? filter.min ?? 0);
51
+ prices.max = Math.floor(selectedFilters.price?.max ?? filter.max ?? 0);
51
52
  });
52
53
 
53
54
  const isFilterVisible = ref<boolean>(false);
@@ -91,8 +92,8 @@ const getClientX = (event: MouseEvent | TouchEvent): number =>
91
92
  const updateSliderValue = (clientX: number) => {
92
93
  if (!dragging.value || !sliderRect.value) return;
93
94
 
94
- const min = props.filter.min ?? 0;
95
- const max = props.filter.max ?? 100;
95
+ const min = filter.min ?? 0;
96
+ const max = filter.max ?? 100;
96
97
  const percent = Math.min(
97
98
  Math.max((clientX - sliderRect.value.left) / sliderRect.value.width, 0),
98
99
  1,
@@ -134,33 +135,37 @@ const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
134
135
 
135
136
  <template>
136
137
  <div class="self-stretch flex flex-col justify-start items-start gap-4">
137
- <div class="self-stretch flex flex-col justify-center items-center">
138
- <div
139
- class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
140
- @click="toggle"
141
- role="button"
142
- tabindex="0"
143
- :aria-expanded="isFilterVisible"
144
- :aria-controls="`filter-${props.filter.code}`"
145
- @keydown.enter="toggle"
146
- @keydown.space.prevent="toggle"
147
- >
148
- <div class="flex-1 flex items-center gap-2.5">
149
- <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
150
- {{ props.filter.label }}
138
+ <!-- Accordion header (only in accordion mode) -->
139
+ <template v-if="displayMode === 'accordion'">
140
+ <div class="self-stretch flex flex-col justify-center items-center">
141
+ <div
142
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
143
+ @click="toggle"
144
+ role="button"
145
+ tabindex="0"
146
+ :aria-expanded="isFilterVisible"
147
+ :aria-controls="`filter-${filter.code}`"
148
+ @keydown.enter="toggle"
149
+ @keydown.space.prevent="toggle"
150
+ >
151
+ <div class="flex-1 flex items-center gap-2.5">
152
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
153
+ {{ filter.label }}
154
+ </div>
151
155
  </div>
156
+ <span
157
+ class="flex items-center justify-center"
158
+ aria-hidden="true"
159
+ >
160
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
161
+ </span>
152
162
  </div>
153
- <SwIconButton
154
- type="ghost"
155
- :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
156
- tabindex="-1"
157
- >
158
- <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
159
- </SwIconButton>
160
163
  </div>
161
- </div>
164
+ </template>
165
+
166
+ <!-- Filter content -->
162
167
  <transition name="filter-collapse">
163
- <div v-if="isFilterVisible" :id="props.filter.code"
168
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" :id="filter.code"
164
169
  class="self-stretch flex flex-col justify-start items-start gap-2.5">
165
170
  <div class="self-stretch flex flex-col justify-start items-start gap-1">
166
171
  <div class="self-stretch inline-flex justify-between items-center gap-2">
@@ -168,15 +173,15 @@ const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
168
173
  class="w-16 h-10 px-2 py-1 rounded-lg outline outline-1 outline-offset-[-1px] outline-outline-outline-variant inline-flex flex-col justify-center items-start gap-2.5">
169
174
  <input type="number" :placeholder="translations.listing.min" v-model.number="prices.min"
170
175
  class="w-full bg-transparent border-none outline-none text-surface-on-surface text-sm font-normal leading-tight"
171
- @change="emits('select-value', { code: props.filter.code, value: { min: prices.min, max: prices.max } })"
172
- :min="props.filter.min" :max="prices.max" />
176
+ @change="emits('select-value', { code: filter.code, value: { min: prices.min, max: prices.max } })"
177
+ :min="filter.min" :max="prices.max" />
173
178
  </div>
174
179
  <div
175
180
  class="w-16 h-10 px-2 py-1 rounded-lg outline outline-1 outline-offset-[-1px] outline-outline-outline-variant inline-flex flex-col justify-center items-start gap-2.5">
176
181
  <input type="number" :placeholder="translations.listing.max" v-model.number="prices.max"
177
182
  class="w-full bg-transparent border-none outline-none text-surface-on-surface text-sm font-normal leading-tight"
178
- @change="emits('select-value', { code: props.filter.code, value: { min: prices.min, max: prices.max } })"
179
- :min="prices.min" :max="props.filter.max" />
183
+ @change="emits('select-value', { code: filter.code, value: { min: prices.min, max: prices.max } })"
184
+ :min="prices.min" :max="filter.max" />
180
185
  </div>
181
186
  </div>
182
187
  <!-- Custom slider UI -->
@@ -187,14 +192,14 @@ const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
187
192
  </div>
188
193
  <!-- Active range -->
189
194
  <div class="absolute top-1/2 -translate-y-1/2 h-2 bg-surface-surface-primary rounded-full" :style="{
190
- left: ((prices.min - (props.filter.min ?? 0)) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100 + '%',
191
- width: ((prices.max - prices.min) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100 + '%',
195
+ left: ((prices.min - (filter.min ?? 0)) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100 + '%',
196
+ width: ((prices.max - prices.min) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100 + '%',
192
197
  }"></div>
193
198
  <!-- Min thumb -->
194
199
  <div
195
200
  class="absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand-primary rounded-full shadow-[2px_2px_10px_0px_rgba(0,0,0,0.15)] cursor-pointer touch-none"
196
201
  :style="{
197
- left: `calc(${((prices.min - (props.filter.min ?? 0)) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100}% - 10px)`
202
+ left: `calc(${((prices.min - (filter.min ?? 0)) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100}% - 10px)`
198
203
  }"
199
204
  @mousedown.prevent="startDrag('min', $event)"
200
205
  @touchstart.prevent="startDrag('min', $event)"></div>
@@ -202,7 +207,7 @@ const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
202
207
  <div
203
208
  class="absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand-primary rounded-full shadow-[2px_2px_10px_0px_rgba(0,0,0,0.15)] cursor-pointer touch-none"
204
209
  :style="{
205
- left: `calc(${((prices.max - (props.filter.min ?? 0)) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100}% - 10px)`
210
+ left: `calc(${((prices.max - (filter.min ?? 0)) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100}% - 10px)`
206
211
  }"
207
212
  @mousedown.prevent="startDrag('max', $event)"
208
213
  @touchstart.prevent="startDrag('max', $event)"></div>