@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 +7 -1
- package/app/components/SwFilterDropdown.vue +54 -0
- package/app/components/SwProductListingFilter.vue +20 -9
- package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
- package/app/components/SwSortDropdown.vue +7 -4
- package/app/components/listing-filters/SwFilterPrice.vue +46 -40
- package/app/components/listing-filters/SwFilterProperties.vue +41 -31
- package/app/components/listing-filters/SwFilterRating.vue +37 -27
- package/app/components/listing-filters/SwFilterShippingFree.vue +40 -32
- package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
- package/app/components/public/cms/section/CmsSectionDefault.vue +0 -1
- package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -1
- package/package.json +1 -1
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-
|
|
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
|
|
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:
|
|
26
|
-
max:
|
|
35
|
+
min: selectedMinPrice,
|
|
36
|
+
max: selectedMaxPrice,
|
|
27
37
|
},
|
|
28
|
-
rating:
|
|
29
|
-
"shipping-free":
|
|
30
|
-
manufacturer: [...
|
|
31
|
-
properties: [...
|
|
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[
|
|
44
|
-
("options" in
|
|
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-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
95
|
-
const max =
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
</
|
|
165
|
+
</template>
|
|
166
|
+
|
|
167
|
+
<!-- Filter content -->
|
|
162
168
|
<transition name="filter-collapse">
|
|
163
|
-
<div v-if="isFilterVisible" :id="
|
|
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:
|
|
172
|
-
:min="
|
|
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:
|
|
179
|
-
:min="prices.min" :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 - (
|
|
191
|
-
width: ((prices.max - prices.min) / ((
|
|
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 - (
|
|
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 - (
|
|
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
|
|
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 (
|
|
39
|
-
return
|
|
43
|
+
if (filter.code === "manufacturer") {
|
|
44
|
+
return selectedFilters?.manufacturer || [];
|
|
40
45
|
}
|
|
41
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
</
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<!-- Filter content -->
|
|
84
94
|
<transition name="filter-collapse">
|
|
85
|
-
<div v-if="isFilterVisible" :id="
|
|
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">{{
|
|
97
|
+
<legend class="sr-only">{{ filter.name }}</legend>
|
|
88
98
|
<label
|
|
89
|
-
v-for="option in
|
|
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
|
|
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 :
|
|
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
|
-
|
|
40
|
+
selectedFilters?.rating !== hoveredIndex.value
|
|
36
41
|
? hoveredIndex.value
|
|
37
42
|
: undefined;
|
|
38
|
-
emits("select-value", { code:
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
</
|
|
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
|
|
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:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
</
|
|
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="
|
|
97
|
-
:aria-label="
|
|
98
|
-
:label="
|
|
99
|
-
:description="
|
|
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
|
|
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>
|
|
@@ -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>
|