@shopware/cms-base-layer 0.0.0-canary-20260114124620 → 0.0.0-canary-20260119085417

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.
package/README.md CHANGED
@@ -349,7 +349,13 @@ No additional packages needed to be installed.
349
349
 
350
350
  Full changelog for stable version is available [here](https://github.com/shopware/frontends/blob/main/packages/cms-base-layer/CHANGELOG.md)
351
351
 
352
- ### Latest changes: 0.0.0-canary-20260114124620
352
+ ### Latest changes: 0.0.0-canary-20260119085417
353
+
354
+ ### Minor Changes
355
+
356
+ - [#2223](https://github.com/shopware/frontends/pull/2223) [`1db8704`](https://github.com/shopware/frontends/commit/1db870413dcea13c690504ffcaee13526bc8035f) Thanks [@mkucmus](https://github.com/mkucmus)! - Add horizontal filter layout for product listings. When the sidebar filter element is placed outside a sidebar section, filters now display as horizontal dropdowns.
357
+
358
+ Includes new `SwFilterDropdown` and `SwProductListingFiltersHorizontal` components, with a `displayMode` prop added to all filter components to support both layouts.
353
359
 
354
360
  ### Patch Changes
355
361
 
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ import { onClickOutside } from "@vueuse/core";
3
+ import { ref, useTemplateRef } from "vue";
4
+
5
+ defineProps<{
6
+ label: string;
7
+ isActive?: boolean;
8
+ }>();
9
+
10
+ const isOpen = ref(false);
11
+ const dropdownElement = useTemplateRef<HTMLDivElement>("dropdownElement");
12
+
13
+ onClickOutside(dropdownElement, () => {
14
+ isOpen.value = false;
15
+ });
16
+
17
+ function toggle() {
18
+ isOpen.value = !isOpen.value;
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <div ref="dropdownElement" class="relative">
24
+ <!-- Pill button -->
25
+ <button
26
+ type="button"
27
+ class="bg-brand-tertiary rounded-full px-4 py-1.5 inline-flex items-center hover:bg-brand-tertiary-hover transition-colors"
28
+ :class="{ 'ring-2 ring-brand-primary': isActive }"
29
+ @click="toggle"
30
+ :aria-expanded="isOpen"
31
+ aria-haspopup="true"
32
+ >
33
+ <div class="py-1 inline-flex items-center gap-1">
34
+ <span class="text-brand-on-tertiary text-base font-normal leading-6">
35
+ {{ label }}
36
+ </span>
37
+ <SwChevronIcon
38
+ :direction="isOpen ? 'up' : 'down'"
39
+ :size="24"
40
+ class="text-brand-on-tertiary"
41
+ />
42
+ </div>
43
+ </button>
44
+
45
+ <!-- Dropdown panel -->
46
+ <div
47
+ v-if="isOpen"
48
+ class="absolute top-full left-0 mt-2 min-w-64 bg-surface-surface rounded-lg shadow-lg ring-1 ring-outline-outline-variant z-50 p-4"
49
+ role="menu"
50
+ >
51
+ <slot />
52
+ </div>
53
+ </div>
54
+ </template>
@@ -6,7 +6,16 @@ import SwFilterPropertiesVue from "./listing-filters/SwFilterProperties.vue";
6
6
  import SwFilterRatingVue from "./listing-filters/SwFilterRating.vue";
7
7
  import SwFilterShippingFreeVue from "./listing-filters/SwFilterShippingFree.vue";
8
8
 
9
- const props = defineProps<{
9
+ const {
10
+ filter,
11
+ selectedManufacturer,
12
+ selectedProperties,
13
+ selectedMinPrice,
14
+ selectedMaxPrice,
15
+ selectedRating,
16
+ selectedShippingFree,
17
+ displayMode = "accordion",
18
+ } = defineProps<{
10
19
  filter: ListingFilter;
11
20
  selectedManufacturer: Set<string>;
12
21
  selectedProperties: Set<string>;
@@ -14,6 +23,7 @@ const props = defineProps<{
14
23
  selectedMaxPrice: number | undefined;
15
24
  selectedRating: number | undefined;
16
25
  selectedShippingFree: boolean | undefined;
26
+ displayMode?: "accordion" | "dropdown";
17
27
  }>();
18
28
 
19
29
  const emit = defineEmits<{
@@ -22,13 +32,13 @@ const emit = defineEmits<{
22
32
 
23
33
  const transformedFilters = computed(() => ({
24
34
  price: {
25
- min: props.selectedMinPrice,
26
- max: props.selectedMaxPrice,
35
+ min: selectedMinPrice,
36
+ max: selectedMaxPrice,
27
37
  },
28
- rating: props.selectedRating,
29
- "shipping-free": props.selectedShippingFree,
30
- manufacturer: [...props.selectedManufacturer],
31
- properties: [...props.selectedProperties],
38
+ rating: selectedRating,
39
+ "shipping-free": selectedShippingFree,
40
+ manufacturer: [...selectedManufacturer],
41
+ properties: [...selectedProperties],
32
42
  }));
33
43
 
34
44
  const filterComponent = computed<Component | undefined>(() => {
@@ -40,8 +50,8 @@ const filterComponent = computed<Component | undefined>(() => {
40
50
  };
41
51
 
42
52
  return (
43
- componentMap[props.filter.code] ||
44
- ("options" in props.filter ? SwFilterPropertiesVue : undefined)
53
+ componentMap[filter.code] ||
54
+ ("options" in filter ? SwFilterPropertiesVue : undefined)
45
55
  );
46
56
  });
47
57
 
@@ -58,6 +68,7 @@ const handleSelectValue = ({
58
68
  :is="filterComponent"
59
69
  :filter="filter"
60
70
  :selected-filters="transformedFilters"
71
+ :display-mode="displayMode"
61
72
  @select-value="handleSelectValue"
62
73
  />
63
74
  </div>
@@ -0,0 +1,306 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ CmsElementProductListing,
4
+ CmsElementSidebarFilter,
5
+ } from "@shopware/composables";
6
+ import { useCmsTranslations } from "@shopware/composables";
7
+ import { defu } from "defu";
8
+ import { computed, reactive } from "vue";
9
+ import type { ComputedRef, UnwrapNestedRefs } from "vue";
10
+ import { type LocationQueryRaw, useRoute, useRouter } from "vue-router";
11
+ import { useCategoryListing } from "#imports";
12
+ import type { Schemas, operations } from "#shopware";
13
+
14
+ const props = defineProps<{
15
+ content: CmsElementProductListing | CmsElementSidebarFilter;
16
+ listingType?: string;
17
+ }>();
18
+
19
+ type Translations = {
20
+ listing: {
21
+ filters: string;
22
+ sort: string;
23
+ resetFilters: string;
24
+ };
25
+ };
26
+
27
+ type FilterState = {
28
+ manufacturer: Set<string>;
29
+ properties: Set<string>;
30
+ "min-price": number | undefined;
31
+ "max-price": number | undefined;
32
+ rating: number | undefined;
33
+ "shipping-free": boolean | undefined;
34
+ };
35
+
36
+ let translations: Translations = {
37
+ listing: {
38
+ filters: "Filters",
39
+ sort: "Sort",
40
+ resetFilters: "Reset filters",
41
+ },
42
+ };
43
+
44
+ translations = defu(useCmsTranslations(), translations) as Translations;
45
+
46
+ const route = useRoute();
47
+ const router = useRouter();
48
+
49
+ const {
50
+ changeCurrentSortingOrder,
51
+ getCurrentSortingOrder,
52
+ getInitialFilters,
53
+ getSortingOrders,
54
+ search,
55
+ } = useCategoryListing();
56
+
57
+ const sidebarSelectedFilters: UnwrapNestedRefs<FilterState> =
58
+ reactive<FilterState>({
59
+ manufacturer: new Set(),
60
+ properties: new Set(),
61
+ "min-price": undefined,
62
+ "max-price": undefined,
63
+ rating: undefined,
64
+ "shipping-free": undefined,
65
+ });
66
+
67
+ const showResetFiltersButton = computed<boolean>(() => {
68
+ if (
69
+ sidebarSelectedFilters.manufacturer.size !== 0 ||
70
+ sidebarSelectedFilters.properties.size !== 0 ||
71
+ sidebarSelectedFilters["max-price"] ||
72
+ sidebarSelectedFilters["min-price"] ||
73
+ sidebarSelectedFilters.rating ||
74
+ sidebarSelectedFilters["shipping-free"]
75
+ ) {
76
+ return true;
77
+ }
78
+
79
+ return false;
80
+ });
81
+
82
+ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
83
+ computed(() => ({
84
+ manufacturer: [
85
+ ...(sidebarSelectedFilters.manufacturer as Set<string>),
86
+ ]?.join("|"),
87
+ properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
88
+ "|",
89
+ ),
90
+ "min-price": sidebarSelectedFilters["min-price"] as number,
91
+ "max-price": sidebarSelectedFilters["max-price"] as number,
92
+ order: getCurrentSortingOrder.value as string,
93
+ "shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
94
+ rating: sidebarSelectedFilters.rating as number,
95
+ search: "",
96
+ limit: route.query.limit ? Number(route.query.limit) : 15,
97
+ }));
98
+
99
+ for (const param in route.query) {
100
+ if (param in sidebarSelectedFilters) {
101
+ const queryValue = route.query[param];
102
+
103
+ // Skip arrays
104
+ if (Array.isArray(queryValue)) continue;
105
+
106
+ if (["manufacturer", "properties"].includes(param)) {
107
+ if (typeof queryValue === "string") {
108
+ const elements = queryValue.split("|");
109
+ const targetSet = sidebarSelectedFilters[
110
+ param as keyof FilterState
111
+ ] as Set<string>;
112
+ for (const element of elements) {
113
+ targetSet.add(element);
114
+ }
115
+ }
116
+ } else if (queryValue && typeof queryValue === "string") {
117
+ // Fix: Use specific property assignments instead of generic keyof
118
+ if (param === "min-price") {
119
+ const numValue = Number(queryValue);
120
+ if (!Number.isNaN(numValue)) {
121
+ sidebarSelectedFilters["min-price"] = numValue;
122
+ }
123
+ } else if (param === "max-price") {
124
+ const numValue = Number(queryValue);
125
+ if (!Number.isNaN(numValue)) {
126
+ sidebarSelectedFilters["max-price"] = numValue;
127
+ }
128
+ } else if (param === "rating") {
129
+ const numValue = Number(queryValue);
130
+ if (!Number.isNaN(numValue)) {
131
+ sidebarSelectedFilters.rating = numValue;
132
+ }
133
+ } else if (param === "shipping-free") {
134
+ sidebarSelectedFilters["shipping-free"] = queryValue === "true";
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ const handleFilterChange = async (event: {
141
+ code: string;
142
+ value: string | number | boolean;
143
+ }) => {
144
+ try {
145
+ const { code, value } = event;
146
+
147
+ if (code === "manufacturer" || code === "properties") {
148
+ const filterSet = sidebarSelectedFilters[code];
149
+ const stringValue = String(value);
150
+
151
+ if (filterSet.has(stringValue)) {
152
+ filterSet.delete(stringValue);
153
+ } else {
154
+ filterSet.add(stringValue);
155
+ }
156
+ } else if (code === "min-price" || code === "max-price") {
157
+ sidebarSelectedFilters[code] =
158
+ typeof value === "number" ? value : Number(value);
159
+ } else if (code === "rating") {
160
+ sidebarSelectedFilters.rating = Number(value);
161
+ } else if (code === "shipping-free") {
162
+ sidebarSelectedFilters["shipping-free"] = Boolean(value);
163
+ }
164
+
165
+ await executeSearch();
166
+ } catch (error) {
167
+ console.error("Filter update failed:", error);
168
+ }
169
+ };
170
+
171
+ const executeSearch = async () => {
172
+ try {
173
+ await search(searchCriteriaForRequest.value);
174
+
175
+ // Build query directly from searchCriteriaForRequest which already has pipe-separated strings
176
+ const criteria = searchCriteriaForRequest.value;
177
+ const query: Record<string, unknown> = {};
178
+
179
+ if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
180
+ if (criteria.properties) query.properties = criteria.properties;
181
+ if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
182
+ if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
183
+ if (criteria.rating) query.rating = criteria.rating;
184
+ if (criteria["shipping-free"])
185
+ query["shipping-free"] = criteria["shipping-free"];
186
+ if (criteria.order) query.order = criteria.order;
187
+
188
+ await router.push({
189
+ query: query as LocationQueryRaw,
190
+ });
191
+ } catch (error) {
192
+ console.error("Search execution failed:", error);
193
+ }
194
+ };
195
+
196
+ const clearFilters = () => {
197
+ (sidebarSelectedFilters.manufacturer as Set<string>).clear();
198
+ (sidebarSelectedFilters.properties as Set<string>).clear();
199
+ sidebarSelectedFilters["min-price"] = undefined;
200
+ sidebarSelectedFilters["max-price"] = undefined;
201
+ sidebarSelectedFilters.rating = undefined;
202
+ sidebarSelectedFilters["shipping-free"] = undefined;
203
+ };
204
+
205
+ const currentSortingOrder = computed({
206
+ get: (): string => getCurrentSortingOrder.value || "",
207
+ set: async (order: string): Promise<void> => {
208
+ try {
209
+ await router.push({
210
+ query: {
211
+ ...route.query,
212
+ order,
213
+ },
214
+ });
215
+
216
+ await changeCurrentSortingOrder(order, {
217
+ ...(route.query as unknown as operations["searchPage post /search"]["body"]),
218
+ limit: route.query.limit ? Number(route.query.limit) : 15,
219
+ });
220
+ } catch (error) {
221
+ console.error("Sorting order change failed:", error);
222
+ }
223
+ },
224
+ });
225
+
226
+ async function invokeCleanFilters() {
227
+ try {
228
+ clearFilters();
229
+ await executeSearch();
230
+ } catch (error) {
231
+ console.error("Clear filters failed:", error);
232
+ }
233
+ }
234
+
235
+ const handleSortChange = (sortKey: string) => {
236
+ currentSortingOrder.value = sortKey;
237
+ };
238
+
239
+ // Helper to check if a filter has active selections
240
+ const hasActiveFilter = (filter: { code: string }) => {
241
+ if (filter.code === "manufacturer") {
242
+ return sidebarSelectedFilters.manufacturer.size > 0;
243
+ }
244
+ if (filter.code === "price") {
245
+ return (
246
+ sidebarSelectedFilters["min-price"] !== undefined ||
247
+ sidebarSelectedFilters["max-price"] !== undefined
248
+ );
249
+ }
250
+ if (filter.code === "rating") {
251
+ return sidebarSelectedFilters.rating !== undefined;
252
+ }
253
+ if (filter.code === "shipping-free") {
254
+ return sidebarSelectedFilters["shipping-free"] === true;
255
+ }
256
+ // Properties filter - check if any property from this filter group is selected
257
+ return sidebarSelectedFilters.properties.size > 0;
258
+ };
259
+ </script>
260
+
261
+ <template>
262
+ <div>
263
+ <!-- Horizontal Filters Row -->
264
+ <div class="flex flex-wrap items-center justify-start gap-4 z-10">
265
+ <!-- Filter dropdowns -->
266
+ <SwFilterDropdown
267
+ v-for="filter in getInitialFilters"
268
+ :key="filter.id"
269
+ :label="filter.label"
270
+ :is-active="hasActiveFilter(filter)"
271
+ >
272
+ <SwProductListingFilter
273
+ :filter="filter"
274
+ display-mode="dropdown"
275
+ :selected-manufacturer="sidebarSelectedFilters.manufacturer"
276
+ :selected-properties="sidebarSelectedFilters.properties"
277
+ :selected-min-price="sidebarSelectedFilters['min-price']"
278
+ :selected-max-price="sidebarSelectedFilters['max-price']"
279
+ :selected-rating="sidebarSelectedFilters.rating"
280
+ :selected-shipping-free="sidebarSelectedFilters['shipping-free']"
281
+ @filter-change="handleFilterChange"
282
+ />
283
+ </SwFilterDropdown>
284
+
285
+ <!-- Sort dropdown -->
286
+ <SwSortDropdown
287
+ :sort-options="getSortingOrders ?? []"
288
+ :current-sort="getCurrentSortingOrder ?? ''"
289
+ :label="translations.listing.sort"
290
+ @sort-change="handleSortChange"
291
+ />
292
+
293
+ <!-- Reset filters button -->
294
+ <SwBaseButton
295
+ v-if="showResetFiltersButton"
296
+ variant="ghost"
297
+ size="medium"
298
+ @click="invokeCleanFilters"
299
+ type="button"
300
+ >
301
+ {{ translations.listing.resetFilters }}
302
+ <span class="w-5 h-5 i-carbon-close inline-block align-middle ml-1"></span>
303
+ </SwBaseButton>
304
+ </div>
305
+ </div>
306
+ </template>
@@ -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<{
@@ -54,7 +57,7 @@ const handleSortingClick = (key: string) => {
54
57
  </SwBaseButton>
55
58
  <div
56
59
  :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"
60
+ 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
61
  role="menu"
59
62
  aria-orientation="vertical"
60
63
  aria-labelledby="menu-button"
@@ -70,11 +73,11 @@ const handleSortingClick = (key: string) => {
70
73
  ? 'font-medium text-surface-on-surface'
71
74
  : 'text-surface-on-surface-variant',
72
75
  ]"
73
- class="block px-4 py-2 text-sm bg-transparent hover:bg-surface-surface-container"
76
+ class="block w-full text-left px-4 py-2 text-sm bg-transparent hover:bg-surface-surface-container"
74
77
  role="menuitem"
75
78
  tabindex="-1"
76
79
  >
77
- {{ sorting.label }}
80
+ {{ sorting.translated?.label }}
78
81
  </button>
79
82
  </div>
80
83
  </div>
@@ -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,38 @@ 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
+ <SwIconButton
157
+ type="ghost"
158
+ :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
159
+ tabindex="-1"
160
+ >
161
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
162
+ </SwIconButton>
152
163
  </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
164
  </div>
161
- </div>
165
+ </template>
166
+
167
+ <!-- Filter content -->
162
168
  <transition name="filter-collapse">
163
- <div v-if="isFilterVisible" :id="props.filter.code"
169
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" :id="filter.code"
164
170
  class="self-stretch flex flex-col justify-start items-start gap-2.5">
165
171
  <div class="self-stretch flex flex-col justify-start items-start gap-1">
166
172
  <div class="self-stretch inline-flex justify-between items-center gap-2">
@@ -168,15 +174,15 @@ const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
168
174
  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
175
  <input type="number" :placeholder="translations.listing.min" v-model.number="prices.min"
170
176
  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" />
177
+ @change="emits('select-value', { code: filter.code, value: { min: prices.min, max: prices.max } })"
178
+ :min="filter.min" :max="prices.max" />
173
179
  </div>
174
180
  <div
175
181
  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
182
  <input type="number" :placeholder="translations.listing.max" v-model.number="prices.max"
177
183
  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" />
184
+ @change="emits('select-value', { code: filter.code, value: { min: prices.min, max: prices.max } })"
185
+ :min="prices.min" :max="filter.max" />
180
186
  </div>
181
187
  </div>
182
188
  <!-- Custom slider UI -->
@@ -187,14 +193,14 @@ const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
187
193
  </div>
188
194
  <!-- Active range -->
189
195
  <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 + '%',
196
+ left: ((prices.min - (filter.min ?? 0)) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100 + '%',
197
+ width: ((prices.max - prices.min) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100 + '%',
192
198
  }"></div>
193
199
  <!-- Min thumb -->
194
200
  <div
195
201
  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
202
  :style="{
197
- left: `calc(${((prices.min - (props.filter.min ?? 0)) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100}% - 10px)`
203
+ left: `calc(${((prices.min - (filter.min ?? 0)) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100}% - 10px)`
198
204
  }"
199
205
  @mousedown.prevent="startDrag('min', $event)"
200
206
  @touchstart.prevent="startDrag('min', $event)"></div>
@@ -202,7 +208,7 @@ const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
202
208
  <div
203
209
  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
210
  :style="{
205
- left: `calc(${((prices.max - (props.filter.min ?? 0)) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100}% - 10px)`
211
+ left: `calc(${((prices.max - (filter.min ?? 0)) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100}% - 10px)`
206
212
  }"
207
213
  @mousedown.prevent="startDrag('max', $event)"
208
214
  @touchstart.prevent="startDrag('max', $event)"></div>
@@ -15,13 +15,18 @@ import { getTranslatedProperty } from "@shopware/helpers";
15
15
  import { computed, ref } from "vue";
16
16
  import type { Schemas } from "#shopware";
17
17
 
18
- const props = defineProps<{
18
+ const {
19
+ filter,
20
+ selectedFilters,
21
+ displayMode = "accordion",
22
+ } = defineProps<{
19
23
  filter: ListingFilter;
20
24
  selectedFilters: {
21
25
  manufacturer?: string[];
22
26
  properties?: string[];
23
27
  [key: string]: unknown;
24
28
  };
29
+ displayMode?: "accordion" | "dropdown";
25
30
  }>();
26
31
 
27
32
  const emits =
@@ -35,17 +40,17 @@ const toggle = () => {
35
40
  };
36
41
 
37
42
  const selectedIds = computed(() => {
38
- if (props.filter.code === "manufacturer") {
39
- return props.selectedFilters?.manufacturer || [];
43
+ if (filter.code === "manufacturer") {
44
+ return selectedFilters?.manufacturer || [];
40
45
  }
41
- return props.selectedFilters?.properties || [];
46
+ return selectedFilters?.properties || [];
42
47
  });
43
48
 
44
49
  const isChecked = (id: string) => selectedIds.value.includes(id);
45
50
 
46
51
  const selectValue = (id: string) => {
47
52
  const emitCode =
48
- props.filter.code === "manufacturer" ? "manufacturer" : "properties";
53
+ filter.code === "manufacturer" ? "manufacturer" : "properties";
49
54
  emits("select-value", {
50
55
  code: emitCode,
51
56
  value: id,
@@ -55,38 +60,43 @@ const selectValue = (id: string) => {
55
60
 
56
61
  <template>
57
62
  <div class="self-stretch flex flex-col justify-start items-start gap-4">
58
- <div class="self-stretch flex flex-col justify-center items-center">
59
- <div
60
- class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
61
- @click="toggle"
62
- role="button"
63
- tabindex="0"
64
- :aria-expanded="isFilterVisible"
65
- :aria-controls="props.filter.code"
66
- :aria-label="props.filter.label"
67
- @keydown.enter="toggle"
68
- @keydown.space.prevent="toggle"
69
- >
70
- <div class="flex-1 flex items-center gap-2.5">
71
- <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
72
- {{ props.filter.label }}
63
+ <!-- Accordion header (only in accordion mode) -->
64
+ <template v-if="displayMode === 'accordion'">
65
+ <div class="self-stretch flex flex-col justify-center items-center">
66
+ <div
67
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
68
+ @click="toggle"
69
+ role="button"
70
+ tabindex="0"
71
+ :aria-expanded="isFilterVisible"
72
+ :aria-controls="filter.code"
73
+ :aria-label="filter.label"
74
+ @keydown.enter="toggle"
75
+ @keydown.space.prevent="toggle"
76
+ >
77
+ <div class="flex-1 flex items-center gap-2.5">
78
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
79
+ {{ filter.label }}
80
+ </div>
73
81
  </div>
82
+ <SwIconButton
83
+ type="ghost"
84
+ :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
85
+ tabindex="-1"
86
+ >
87
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
88
+ </SwIconButton>
74
89
  </div>
75
- <SwIconButton
76
- type="ghost"
77
- :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
78
- tabindex="-1"
79
- >
80
- <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
81
- </SwIconButton>
82
90
  </div>
83
- </div>
91
+ </template>
92
+
93
+ <!-- Filter content -->
84
94
  <transition name="filter-collapse">
85
- <div v-if="isFilterVisible" :id="props.filter.code" class="self-stretch flex flex-col justify-start items-start gap-4">
95
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" :id="filter.code" class="self-stretch flex flex-col justify-start items-start gap-4">
86
96
  <fieldset class="self-stretch flex flex-col justify-start items-start gap-4">
87
- <legend class="sr-only">{{ props.filter.name }}</legend>
97
+ <legend class="sr-only">{{ filter.name }}</legend>
88
98
  <label
89
- v-for="option in props.filter.options || props.filter.entities"
99
+ v-for="option in filter.options || filter.entities"
90
100
  :key="`${option.id}-${isChecked(option.id)}`"
91
101
  class="self-stretch inline-flex justify-start items-start gap-2 cursor-pointer"
92
102
  @click="selectValue(option.id)"
@@ -16,14 +16,19 @@ const emits =
16
16
  (e: "select-value", value: { code: string; value: unknown }) => void
17
17
  >();
18
18
 
19
- const props = defineProps<{
19
+ const {
20
+ filter,
21
+ selectedFilters,
22
+ displayMode = "accordion",
23
+ } = defineProps<{
20
24
  filter: ListingFilter;
21
25
  selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
26
+ displayMode?: "accordion" | "dropdown";
22
27
  }>();
23
28
  const isHoverActive = ref(false);
24
29
  const hoveredIndex = ref(0);
25
30
  const displayedScore = computed(() =>
26
- isHoverActive.value ? hoveredIndex.value : props.selectedFilters?.rating || 0,
31
+ isHoverActive.value ? hoveredIndex.value : selectedFilters?.rating || 0,
27
32
  );
28
33
 
29
34
  const hoverRating = (key: number) => {
@@ -32,10 +37,10 @@ const hoverRating = (key: number) => {
32
37
  };
33
38
  const onChangeRating = () => {
34
39
  const newValue =
35
- props.selectedFilters?.rating !== hoveredIndex.value
40
+ selectedFilters?.rating !== hoveredIndex.value
36
41
  ? hoveredIndex.value
37
42
  : undefined;
38
- emits("select-value", { code: props.filter?.code, value: newValue });
43
+ emits("select-value", { code: filter?.code, value: newValue });
39
44
  };
40
45
 
41
46
  const isFilterVisible = ref<boolean>(false);
@@ -46,33 +51,38 @@ const toggle = () => {
46
51
 
47
52
  <template>
48
53
  <div class="self-stretch flex flex-col justify-start items-start gap-4">
49
- <div class="self-stretch flex flex-col justify-center items-center">
50
- <div
51
- class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
52
- @click="toggle"
53
- role="button"
54
- tabindex="0"
55
- :aria-expanded="isFilterVisible"
56
- :aria-controls="`filter-rating`"
57
- @keydown.enter="toggle"
58
- @keydown.space.prevent="toggle"
59
- >
60
- <div class="flex-1 flex items-center gap-2.5">
61
- <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
62
- {{ props.filter.label }}
54
+ <!-- Accordion header (only in accordion mode) -->
55
+ <template v-if="displayMode === 'accordion'">
56
+ <div class="self-stretch flex flex-col justify-center items-center">
57
+ <div
58
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
59
+ @click="toggle"
60
+ role="button"
61
+ tabindex="0"
62
+ :aria-expanded="isFilterVisible"
63
+ :aria-controls="`filter-rating`"
64
+ @keydown.enter="toggle"
65
+ @keydown.space.prevent="toggle"
66
+ >
67
+ <div class="flex-1 flex items-center gap-2.5">
68
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
69
+ {{ filter.label }}
70
+ </div>
63
71
  </div>
72
+ <SwIconButton
73
+ type="ghost"
74
+ :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
75
+ tabindex="-1"
76
+ >
77
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
78
+ </SwIconButton>
64
79
  </div>
65
- <SwIconButton
66
- type="ghost"
67
- :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
68
- tabindex="-1"
69
- >
70
- <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
71
- </SwIconButton>
72
80
  </div>
73
- </div>
81
+ </template>
82
+
83
+ <!-- Filter content -->
74
84
  <transition name="filter-collapse">
75
- <div v-if="isFilterVisible" class="self-stretch flex flex-col justify-start items-start gap-4">
85
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" class="self-stretch flex flex-col justify-start items-start gap-4">
76
86
  <div class="flex flex-row items-center gap-2 mt-2">
77
87
  <div
78
88
  v-for="i in 5"
@@ -16,10 +16,16 @@ import { defu } from "defu";
16
16
  import { computed, ref } from "vue";
17
17
  import type { Schemas } from "#shopware";
18
18
 
19
- const props = defineProps<{
19
+ const {
20
+ filter,
21
+ selectedFilters,
22
+ description,
23
+ displayMode = "accordion",
24
+ } = defineProps<{
20
25
  filter: ListingFilter;
21
26
  selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
22
27
  description?: string; // Optional description for i18n
28
+ displayMode?: "accordion" | "dropdown";
23
29
  }>();
24
30
 
25
31
  type Translations = {
@@ -38,9 +44,7 @@ const emits =
38
44
  defineEmits<
39
45
  (e: "select-value", value: { code: string; value: unknown }) => void
40
46
  >();
41
- const currentFilterData = computed(
42
- () => !!props.selectedFilters[props.filter?.code],
43
- );
47
+ const currentFilterData = computed(() => !!selectedFilters[filter?.code]);
44
48
 
45
49
  const isFilterVisible = ref<boolean>(false);
46
50
  const toggle = () => {
@@ -53,50 +57,54 @@ onClickOutside(dropdownElement, () => {
53
57
  });
54
58
 
55
59
  const handleRadioUpdate = (val: string | null | boolean | undefined) => {
56
- emits("select-value", { code: props.filter.code, value: !!val });
60
+ emits("select-value", { code: filter.code, value: !!val });
57
61
  };
58
62
  </script>
59
63
 
60
64
  <template>
61
65
  <div class="self-stretch flex flex-col justify-start items-start gap-4">
62
- <div class="self-stretch flex flex-col justify-center items-center">
63
- <div
64
- class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
65
- @click="toggle"
66
- role="button"
67
- tabindex="0"
68
- :aria-expanded="isFilterVisible"
69
- :aria-controls="`filter-${props.filter.code}`"
70
- @keydown.enter="toggle"
71
- @keydown.space.prevent="toggle"
72
- >
73
- <div class="flex-1 flex items-center gap-2.5">
74
- <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
75
- {{ props.filter.label }}
66
+ <!-- Accordion header (only in accordion mode) -->
67
+ <template v-if="displayMode === 'accordion'">
68
+ <div class="self-stretch flex flex-col justify-center items-center">
69
+ <div
70
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
71
+ @click="toggle"
72
+ role="button"
73
+ tabindex="0"
74
+ :aria-expanded="isFilterVisible"
75
+ :aria-controls="`filter-${filter.code}`"
76
+ @keydown.enter="toggle"
77
+ @keydown.space.prevent="toggle"
78
+ >
79
+ <div class="flex-1 flex items-center gap-2.5">
80
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
81
+ {{ filter.label }}
82
+ </div>
76
83
  </div>
84
+ <SwIconButton
85
+ type="ghost"
86
+ :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
87
+ tabindex="-1"
88
+ >
89
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
90
+ </SwIconButton>
77
91
  </div>
78
- <SwIconButton
79
- type="ghost"
80
- :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
81
- tabindex="-1"
82
- >
83
- <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
84
- </SwIconButton>
85
92
  </div>
86
- </div>
93
+ </template>
87
94
 
95
+ <!-- Filter content -->
88
96
  <transition name="filter-collapse">
89
- <div v-if="isFilterVisible" class="self-stretch">
97
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" class="self-stretch">
90
98
  <div class="pt-6 space-y-4">
91
99
  <div class="self-stretch inline-flex justify-start items-start gap-2 w-full">
92
100
  <div class="flex-1 pt-[3px]">
93
101
  <SwSwitchButton
94
102
  :model-value="currentFilterData"
95
103
  @update:model-value="handleRadioUpdate"
96
- :name="props.filter.code"
97
- :aria-label="props.filter.label"
98
- :label="props.filter.label"
99
- :description="props.description || translations.listing.freeShipping"
104
+ :name="filter.code"
105
+ :aria-label="filter.label"
106
+ :label="filter.label"
107
+ :description="description || translations.listing.freeShipping"
100
108
  />
101
109
  </div>
102
110
  </div>
@@ -1,12 +1,20 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementSidebarFilter } from "@shopware/composables";
3
+ import { inject } from "vue";
3
4
 
4
5
  defineProps<{
5
6
  content: CmsElementSidebarFilter;
6
7
  }>();
8
+
9
+ // Inject layout context from parent section
10
+ // If in sidebar section -> use vertical accordion filters
11
+ // Otherwise -> use horizontal dropdown filters
12
+ const sectionLayout = inject<string>("cms-section-layout", "default");
13
+ const isInSidebar = sectionLayout === "sidebar";
7
14
  </script>
8
15
  <template>
9
- <div class="max-w-screen-xl mx-auto">
10
- <SwProductListingFilters :content="content" />
16
+ <div>
17
+ <SwProductListingFilters v-if="isInSidebar" :content="content" />
18
+ <SwProductListingFiltersHorizontal v-else :content="content" />
11
19
  </div>
12
20
  </template>
@@ -14,7 +14,6 @@ const { cssClasses, layoutStyles } = getCmsLayoutConfiguration(props.content);
14
14
  <CmsGenericBlock
15
15
  v-for="cmsBlock in content.blocks"
16
16
  :key="cmsBlock.id"
17
- class="overflow-auto"
18
17
  :content="cmsBlock"
19
18
  />
20
19
  </div>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { useCmsSection } from "@shopware/composables";
3
3
  import type { CmsSectionSidebar } from "@shopware/composables";
4
- import { computed } from "vue";
4
+ import { computed, provide } from "vue";
5
5
 
6
6
  const props = defineProps<{
7
7
  content: CmsSectionSidebar;
@@ -12,6 +12,9 @@ const sidebarBlocks = getPositionContent("sidebar");
12
12
  const mainBlocks = getPositionContent("main");
13
13
  const mobileBehavior = computed(() => props.content.mobileBehavior);
14
14
  const fullWidth = computed(() => section.sizingMode === "full_width");
15
+
16
+ // Provide layout context for child components
17
+ provide("cms-section-layout", "sidebar");
15
18
  </script>
16
19
 
17
20
  <template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopware/cms-base-layer",
3
- "version": "0.0.0-canary-20260114124620",
3
+ "version": "0.0.0-canary-20260119085417",
4
4
  "description": "Vue CMS Nuxt Layer for Shopware",
5
5
  "author": "Shopware",
6
6
  "repository": {