@shopware/cms-base-layer 3.1.0 → 4.0.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 (37) hide show
  1. package/README.md +64 -23
  2. package/app/app.config.ts +3 -3
  3. package/app/assets/css/rich-text.css +80 -0
  4. package/app/components/SwContactForm.vue +2 -1
  5. package/app/components/SwFilterChips.vue +29 -7
  6. package/app/components/SwNewsletterForm.vue +2 -1
  7. package/app/components/SwProductCardImage.vue +5 -1
  8. package/app/components/SwProductListingFilter.vue +5 -0
  9. package/app/components/SwProductListingFilters.vue +40 -4
  10. package/app/components/SwProductListingFiltersHorizontal.vue +37 -2
  11. package/app/components/SwStockInfo.vue +5 -2
  12. package/app/components/SwVariantConfigurator.vue +8 -3
  13. package/app/components/listing-filters/SwFilterCategories.vue +158 -0
  14. package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
  15. package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
  16. package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
  17. package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
  18. package/app/components/public/cms/element/CmsElementImage.vue +41 -1
  19. package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
  20. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +2 -2
  21. package/app/components/public/cms/element/CmsElementText.md +77 -0
  22. package/app/components/public/cms/element/CmsElementText.vue +5 -32
  23. package/app/helpers/html-to-vue/renderToHtml.test.ts +55 -0
  24. package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
  25. package/app/helpers/html-to-vue/renderer.ts +10 -1
  26. package/app/utils/listingFilters.test.ts +39 -0
  27. package/app/utils/listingFilters.ts +19 -0
  28. package/app/utils/useSelectedListingFilters.test.ts +13 -3
  29. package/app/utils/useSelectedListingFilters.ts +7 -1
  30. package/component-dirs.json +20 -0
  31. package/index.d.ts +2 -2
  32. package/nuxt.config.ts +11 -28
  33. package/package.json +20 -23
  34. package/dist/index.d.mts +0 -5
  35. package/dist/index.d.ts +0 -5
  36. package/dist/index.mjs +0 -31
  37. package/index.cjs +0 -7
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { getTranslatedProperty } from "@shopware/helpers";
2
3
  import { defu } from "defu";
3
4
 
4
5
  import { useCmsTranslations } from "#imports";
@@ -37,13 +38,15 @@ translations = defu(useCmsTranslations(), translations) as Translations;
37
38
  ></div>
38
39
  <div class="w-2 h-2 bg-states-error rounded-full" v-else></div>
39
40
  <span v-if="availableStock >= minPurchase && deliveryTime"
40
- >{{ translations.product.deliveryTime }} {{ deliveryTime?.name }}
41
+ >{{ translations.product.deliveryTime }}
42
+ {{ getTranslatedProperty(deliveryTime, "name") }}
41
43
  </span>
42
44
  <span
43
45
  v-else-if="availableStock < minPurchase && deliveryTime && restockTime"
44
46
  >
45
47
  {{ translations.product.deliveryTime }} {{ restockTime }}
46
- {{ translations.product.days }} {{ deliveryTime?.name }}</span
48
+ {{ translations.product.days }}
49
+ {{ getTranslatedProperty(deliveryTime, "name") }}</span
47
50
  >
48
51
  <span v-else>{{ translations.product.noAvailable }}</span>
49
52
  </div>
@@ -1,6 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { useCmsTranslations } from "@shopware/composables";
3
- import { buildUrlPrefix, getProductRoute } from "@shopware/helpers";
3
+ import {
4
+ buildUrlPrefix,
5
+ getProductRoute,
6
+ getTranslatedProperty,
7
+ } from "@shopware/helpers";
4
8
  import { defu } from "defu";
5
9
  import { computed, ref, unref } from "vue";
6
10
  import type { ComputedRef } from "vue";
@@ -86,11 +90,12 @@ const onHandleChange = async () => {
86
90
  class="mt-6"
87
91
  >
88
92
  <div class="text-sm text-gray-900 font-medium">
89
- {{ optionGroup.name }}
93
+ {{ getTranslatedProperty(optionGroup, "name") }}
90
94
  </div>
91
95
  <fieldset class="mt-4 flex-1">
92
96
  <legend class="sr-only">
93
- {{ translations.product.chooseA }} {{ optionGroup.name }}
97
+ {{ translations.product.chooseA }}
98
+ {{ getTranslatedProperty(optionGroup, "name") }}
94
99
  </legend>
95
100
  <div class="flex gap-3">
96
101
  <label
@@ -0,0 +1,158 @@
1
+ <script
2
+ setup
3
+ lang="ts"
4
+ generic="
5
+ ListingFilter extends {
6
+ code: string;
7
+ label: string;
8
+ name: string;
9
+ entities?: Array<{
10
+ id: string;
11
+ name?: string;
12
+ translated?: { name?: string };
13
+ }>;
14
+ }
15
+ "
16
+ >
17
+ import { useCmsTranslations } from "@shopware/composables";
18
+ import { excludeRootCategory, getTranslatedProperty } from "@shopware/helpers";
19
+ import { defu } from "defu";
20
+ import { computed, ref } from "vue";
21
+
22
+ import { useSessionContext } from "#imports";
23
+
24
+ const {
25
+ filter,
26
+ selectedFilters,
27
+ displayMode = "accordion",
28
+ } = defineProps<{
29
+ filter: ListingFilter;
30
+ selectedFilters: {
31
+ categories?: string[];
32
+ [key: string]: unknown;
33
+ };
34
+ displayMode?: "accordion" | "dropdown";
35
+ }>();
36
+
37
+ const emits =
38
+ defineEmits<
39
+ (e: "select-value", value: { code: string; value: unknown }) => void
40
+ >();
41
+
42
+ type Translations = {
43
+ listing: {
44
+ categories: string;
45
+ };
46
+ };
47
+
48
+ let translations: Translations = {
49
+ listing: {
50
+ categories: "Categories",
51
+ },
52
+ };
53
+
54
+ translations = defu(useCmsTranslations(), translations) as Translations;
55
+
56
+ const { sessionContext } = useSessionContext();
57
+
58
+ const categoryOptions = computed(() =>
59
+ excludeRootCategory(
60
+ filter.entities,
61
+ sessionContext.value?.salesChannel?.navigationCategoryId,
62
+ ),
63
+ );
64
+
65
+ const isFilterVisible = ref<boolean>(false);
66
+ const toggle = () => {
67
+ isFilterVisible.value = !isFilterVisible.value;
68
+ };
69
+
70
+ const selectedIds = computed(() => selectedFilters?.categories || []);
71
+
72
+ const isChecked = (id: string) => selectedIds.value.includes(id);
73
+
74
+ const selectValue = (id: string) => {
75
+ emits("select-value", {
76
+ code: filter.code,
77
+ value: id,
78
+ });
79
+ };
80
+ </script>
81
+
82
+ <template>
83
+ <div
84
+ v-if="categoryOptions.length"
85
+ class="self-stretch flex flex-col justify-start items-start gap-4"
86
+ >
87
+ <!-- Accordion header (only in accordion mode) -->
88
+ <template v-if="displayMode === 'accordion'">
89
+ <div class="self-stretch flex flex-col justify-center items-center">
90
+ <div
91
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
92
+ @click="toggle"
93
+ role="button"
94
+ tabindex="0"
95
+ :aria-expanded="isFilterVisible"
96
+ :aria-controls="filter.code"
97
+ :aria-label="translations.listing.categories"
98
+ @keydown.enter="toggle"
99
+ @keydown.space.prevent="toggle"
100
+ >
101
+ <div class="flex-1 flex items-center gap-2.5">
102
+ <div
103
+ class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left"
104
+ >
105
+ {{ translations.listing.categories }}
106
+ </div>
107
+ </div>
108
+ <span class="flex items-center justify-center" aria-hidden="true">
109
+ <SwChevronIcon
110
+ :direction="isFilterVisible ? 'up' : 'down'"
111
+ :size="24"
112
+ />
113
+ </span>
114
+ </div>
115
+ </div>
116
+ </template>
117
+
118
+ <!-- Filter content -->
119
+ <transition name="filter-collapse">
120
+ <div
121
+ v-if="isFilterVisible || displayMode === 'dropdown'"
122
+ :id="filter.code"
123
+ class="self-stretch flex flex-col justify-start items-start gap-4"
124
+ >
125
+ <fieldset
126
+ class="self-stretch flex flex-col justify-start items-start gap-4"
127
+ >
128
+ <legend class="sr-only">{{ translations.listing.categories }}</legend>
129
+ <label
130
+ v-for="option in categoryOptions"
131
+ :key="option.id"
132
+ class="self-stretch inline-flex justify-start items-start gap-2 cursor-pointer"
133
+ >
134
+ <div
135
+ class="w-4 self-stretch pt-[3px] flex justify-start items-start gap-2.5"
136
+ >
137
+ <SwCheckbox
138
+ :model-value="isChecked(option.id)"
139
+ @update:model-value="() => selectValue(option.id)"
140
+ />
141
+ </div>
142
+ <div
143
+ class="flex-1 inline-flex flex-col justify-start items-start gap-0.5"
144
+ >
145
+ <div class="inline-flex justify-start items-center gap-1">
146
+ <div
147
+ class="flex-1 text-surface-on-surface text-base font-normal leading-normal"
148
+ >
149
+ {{ getTranslatedProperty(option, "name") }}
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </label>
154
+ </fieldset>
155
+ </div>
156
+ </transition>
157
+ </div>
158
+ </template>
@@ -38,7 +38,7 @@ useSeoMeta({
38
38
  >
39
39
  <div
40
40
  v-if="registrationResponse?.translated.registrationIntroduction"
41
- class="px-6 sm:px-4 mb-6"
41
+ class="cms-element-text px-6 sm:px-4 mb-6"
42
42
  v-html="registrationResponse.translated.registrationIntroduction"
43
43
  />
44
44
  <AccountRegistrationForm
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementBuyBox } from "@shopware/composables";
3
3
  import { useCmsTranslations } from "@shopware/composables";
4
+ import { getTranslatedProperty } from "@shopware/helpers";
4
5
  import { defu } from "defu";
5
6
  import { computed } from "vue";
6
7
 
@@ -64,7 +65,9 @@ const referencePrice = computed(
64
65
  () => product.value?.calculatedPrice?.referencePrice,
65
66
  );
66
67
  const purchaseUnit = computed(() => product.value?.purchaseUnit);
67
- const unitName = computed(() => product.value?.unit?.name);
68
+ const unitName = computed(() =>
69
+ getTranslatedProperty(product.value?.unit, "name"),
70
+ );
68
71
  const productName = computed(() => product.value?.translated?.name || "");
69
72
  </script>
70
73
  <template>
@@ -3,6 +3,7 @@ import type {
3
3
  CmsElementCrossSelling,
4
4
  SliderElementConfig,
5
5
  } from "@shopware/composables";
6
+ import { getTranslatedProperty } from "@shopware/helpers";
6
7
  import { useElementSize } from "@vueuse/core";
7
8
  import { computed, inject, ref, useTemplateRef } from "vue";
8
9
 
@@ -86,7 +87,7 @@ const toggleTab = (index: number) => {
86
87
  }"
87
88
  @click="toggleTab(index)"
88
89
  >
89
- {{ collection.crossSelling.name }}
90
+ {{ getTranslatedProperty(collection.crossSelling, "name") }}
90
91
  </a>
91
92
  </div>
92
93
  <transition name="fade" mode="out-in">
@@ -11,7 +11,7 @@ const { cssClasses, layoutStyles } = getCmsLayoutConfiguration(props.content);
11
11
 
12
12
  const HtmlComponent = () => {
13
13
  return h("div", {
14
- class: cssClasses,
14
+ class: ["cms-element-html", "cms-element-text", cssClasses],
15
15
  style: layoutStyles,
16
16
  innerHTML: props.content.data.content || "",
17
17
  });
@@ -5,9 +5,14 @@ import type {
5
5
  } from "@shopware/composables";
6
6
  import { buildUrlPrefix } from "@shopware/helpers";
7
7
  import { useElementSize } from "@vueuse/core";
8
+ import { defu } from "defu";
8
9
  import { computed, defineAsyncComponent, useTemplateRef } from "vue";
9
10
 
10
- import { useCmsElementImage, useUrlResolver } from "#imports";
11
+ import {
12
+ useCmsElementImage,
13
+ useCmsTranslations,
14
+ useUrlResolver,
15
+ } from "#imports";
11
16
 
12
17
  import { isSpatial } from "../../../../helpers/media/isSpatial";
13
18
 
@@ -16,17 +21,51 @@ const props = defineProps<{
16
21
  imageGallery?: boolean;
17
22
  }>();
18
23
 
24
+ type Translations = {
25
+ cms: {
26
+ image: {
27
+ linkWithoutLabel: string;
28
+ };
29
+ };
30
+ };
31
+
32
+ let translations: Translations = {
33
+ cms: {
34
+ image: {
35
+ linkWithoutLabel: "Open linked page",
36
+ },
37
+ },
38
+ };
39
+
40
+ translations = defu(useCmsTranslations(), translations) as Translations;
41
+
19
42
  const { getUrlPrefix } = useUrlResolver();
20
43
  const {
44
+ ariaLabel,
21
45
  containerStyle,
22
46
  displayMode,
23
47
  imageContainerAttrs,
24
48
  imageAttrs,
25
49
  imageLink,
50
+ isDecorative,
26
51
  isVideoElement,
27
52
  mimeType,
28
53
  } = useCmsElementImage(props.content);
29
54
 
55
+ // A link wrapping only an image is nameless without alt text, which axe
56
+ // reports as a `link-name` violation.
57
+ const linkAriaLabel = computed(() => {
58
+ if (!imageLink.value.url || imageAttrs.value.alt) {
59
+ return undefined;
60
+ }
61
+
62
+ const mediaTitle = isDecorative.value ? "" : props.content.data?.media?.title;
63
+
64
+ return (
65
+ ariaLabel.value || mediaTitle || translations.cms.image.linkWithoutLabel
66
+ );
67
+ });
68
+
30
69
  const imageElement = useTemplateRef<HTMLImageElement>("imageElement");
31
70
  const { width, height } = useElementSize(imageElement);
32
71
 
@@ -69,6 +108,7 @@ const SwMedia3D = computed(() => {
69
108
  }"
70
109
  :style="containerStyle"
71
110
  v-bind="imageComputedContainerAttrs"
111
+ :aria-label="linkAriaLabel"
72
112
  >
73
113
  <video
74
114
  v-if="isVideoElement"
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementImageGallery } from "@shopware/composables";
3
+ import { getTranslatedProperty } from "@shopware/helpers";
3
4
  import { computed, defineAsyncComponent, ref } from "vue";
4
5
 
5
6
  import { useCmsElementConfig, useImagePlaceholder } from "#imports";
@@ -129,7 +130,7 @@ function onTouchEnd() {
129
130
  class="w-full h-full absolute inset-0 object-cover"
130
131
  :placeholder="placeholderSvg"
131
132
  :src="currentImage.url"
132
- :alt="currentImage.alt || 'Product image'"
133
+ :alt="getTranslatedProperty(currentImage, 'alt') || 'Product image'"
133
134
  />
134
135
  <!-- Placeholder -->
135
136
  <img
@@ -133,7 +133,7 @@ onMounted(async () => {
133
133
  class="self-stretch text-surface-on-surface text-base font-normal leading-normal"
134
134
  >
135
135
  <!-- eslint-disable-next-line vue/no-v-html -->
136
- <div v-html="description"></div>
136
+ <div class="cms-element-text" v-html="description"></div>
137
137
  </div>
138
138
  </div>
139
139
  </Transition>
@@ -242,7 +242,7 @@ onMounted(async () => {
242
242
  :key="category.id"
243
243
  class="mb-2"
244
244
  >
245
- {{ category.name }}
245
+ {{ getTranslatedProperty(category, "name") }}
246
246
  </div>
247
247
  </div>
248
248
  <div v-else>No categories available</div>
@@ -1 +1,78 @@
1
1
  Display a text. Html to Vue mechanism is used to render buttons, links, images accordingly as Vue elements
2
+
3
+ #### Styling CMS-authored HTML
4
+
5
+ The rendered container always carries the `cms-element-text` class. Because the
6
+ content comes from the Shopware admin editor, its tags (`h1`–`h6`, `ul`, `table`,
7
+ `img`, …) arrive without any classes, so they can only be reached with element
8
+ selectors — and those must never be declared globally, or they would also hit
9
+ product names, buttons and other markup you do control.
10
+
11
+ This layer ships that typography for you in
12
+ [`app/assets/css/rich-text.css`](../../../../assets/css/rich-text.css),
13
+ registered through `css: []` in the layer's `nuxt.config.ts`. It is plain CSS, so
14
+ it also works for apps that don't use UnoCSS. Nothing to wire up in your app.
15
+
16
+ ##### Retheming
17
+
18
+ Every value reads from a `--rte-*` custom property with a built-in fallback, so
19
+ you only set the properties you want to change — no need to redeclare the rules:
20
+
21
+ ```css
22
+ :root {
23
+ --rte-h1-size: 3rem;
24
+ --rte-h1-line: 3.25rem;
25
+ --rte-border-color: var(--color-outline-variant);
26
+ }
27
+ ```
28
+
29
+ | Property | Default |
30
+ | --------------------------------- | ------------------------------- |
31
+ | `--rte-h1-size` / `--rte-h1-line` | `2.25rem` / `2.5rem` |
32
+ | `--rte-h2-size` / `--rte-h2-line` | `1.75rem` / `2rem` |
33
+ | `--rte-h3-size` / `--rte-h3-line` | `1.25rem` / `1.5rem` |
34
+ | `--rte-h4-size` / `--rte-h4-line` | `1.125rem` / `1.5rem` (h4–h6) |
35
+ | `--rte-heading-weight` | `600` |
36
+ | `--rte-heading-mb` | `10px` |
37
+ | `--rte-flow` | `1rem` (spacing between blocks) |
38
+ | `--rte-list-indent` | `40px` |
39
+ | `--rte-cell-padding` | `0.5rem 0.75rem` |
40
+ | `--rte-border-color` | `#e5e7eb` (tables, `hr`) |
41
+
42
+ Because the properties are inherited, you can also scope them contextually
43
+ instead of overriding selectors:
44
+
45
+ ```css
46
+ .cms-block-image-text-cover .cms-element-text {
47
+ --rte-h1-size: 3.5rem;
48
+ }
49
+ ```
50
+
51
+ ##### Scope
52
+
53
+ The class is set everywhere admin-authored HTML is injected, so one rule set
54
+ covers all of them:
55
+
56
+ | Component | Classes |
57
+ | ---------------------------------------------- | ------------------------------------- |
58
+ | `CmsElementText` | `cms-element-text` |
59
+ | `CmsElementHtml` | `cms-element-html cms-element-text` |
60
+ | `CmsElementProductDescriptionReviews` | `cms-element-text` (description body) |
61
+ | `FrontendAccountCustomerGroupRegistrationPage` | `cms-element-text` (intro text) |
62
+
63
+ Add `cms-element-text` to your own wrappers if you render admin HTML elsewhere,
64
+ for example custom fields using the HTML editor.
65
+
66
+ ##### Notes
67
+
68
+ The stylesheet uses `:where()` throughout, which keeps the specificity at
69
+ `(0,1,0)`. Two consequences worth knowing:
70
+
71
+ - A utility class or an inline style set by the editor still wins, without
72
+ `!important`.
73
+ - The rules beat a framework reset (`ul { list-style: none }`) no matter which
74
+ stylesheet is injected first, so the CSS order does not matter.
75
+
76
+ A `<style scoped>` block inside this component would _not_ work: the markup is
77
+ created by a render function, and Vue only applies the scope id to the container
78
+ root, not to its descendants.
@@ -32,6 +32,10 @@ const CmsTextRender = defineComponent({
32
32
  const { resolveUrl } = useUrlResolver();
33
33
 
34
34
  const config = {
35
+ container: {
36
+ type: "div",
37
+ class: "cms-element-text",
38
+ },
35
39
  textTransformer: (text: string) => decodeHTML(text),
36
40
  extraComponentsMap: {
37
41
  link: {
@@ -148,7 +152,7 @@ const CmsTextRender = defineComponent({
148
152
  const rawHtml =
149
153
  mappedContent.value?.length > 0
150
154
  ? mappedContent.value
151
- : "<div class='cms-element-text missing-content-element'></div>";
155
+ : "<div class='missing-content-element'></div>";
152
156
 
153
157
  return () => renderHtml(rawHtml, config, h, context, resolveUrl);
154
158
  },
@@ -160,34 +164,3 @@ const CmsTextRender = defineComponent({
160
164
  </div>
161
165
  <CmsTextRender v-else />
162
166
  </template>
163
- <style scoped>
164
- /** Global CSS styles for text elements */
165
- h1,
166
- h2,
167
- h3,
168
- h4,
169
- h5 {
170
- margin-bottom: 10px;
171
- font-weight: 600;
172
- }
173
- h1 {
174
- line-height: 2.5rem;
175
- font-size: 2.25rem;
176
- }
177
- h2 {
178
- line-height: 2rem;
179
- font-size: 1.75rem;
180
- }
181
- h3 {
182
- line-height: 1.5rem;
183
- font-size: 1.25rem;
184
- }
185
- ol,
186
- ul,
187
- dl {
188
- list-style-type: disc;
189
- padding-left: 40px;
190
- margin-top: 0;
191
- margin-bottom: 1rem;
192
- }
193
- </style>
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { h } from "vue";
3
+
4
+ import { renderHtml } from "./renderToHtml";
5
+
6
+ describe("renderHtml", () => {
7
+ const resolveUrl = (url: string) => url;
8
+
9
+ const render = (
10
+ html: string,
11
+ config: Parameters<typeof renderHtml>[1] = {},
12
+ ) => renderHtml(html, config, h, null, resolveUrl);
13
+
14
+ it("wraps the content in a plain div by default", () => {
15
+ const vnode = render("<p>content</p>");
16
+
17
+ expect(vnode.type).toBe("div");
18
+ expect(vnode.props?.class).toBeUndefined();
19
+ });
20
+
21
+ it("applies container.class to the wrapper element", () => {
22
+ const vnode = render("<h1>headline</h1>", {
23
+ container: { type: "div", class: "cms-element-text" },
24
+ });
25
+
26
+ expect(vnode.props?.class).toBe("cms-element-text");
27
+ });
28
+
29
+ it("honours a custom container type", () => {
30
+ const vnode = render("<p>content</p>", {
31
+ container: { type: "section", class: "cms-element-text" },
32
+ });
33
+
34
+ expect(vnode.type).toBe("section");
35
+ });
36
+
37
+ it("does not leak container.class into later renders", () => {
38
+ render("<p>scoped</p>", {
39
+ container: { type: "div", class: "cms-element-text" },
40
+ });
41
+
42
+ const vnode = render("<p>unscoped</p>");
43
+
44
+ expect(vnode.props?.class).toBeUndefined();
45
+ });
46
+
47
+ it("keeps rendering the parsed children next to the container class", () => {
48
+ const vnode = render("<h2>headline</h2>", {
49
+ container: { type: "div", class: "cms-element-text" },
50
+ });
51
+
52
+ expect(Array.isArray(vnode.children)).toBe(true);
53
+ expect((vnode.children as { type: string }[])[0]?.type).toBe("h2");
54
+ });
55
+ });
@@ -25,7 +25,11 @@ export function renderHtml(
25
25
  context: ComponentInternalInstance | null,
26
26
  resolveUrl: (url: string) => string,
27
27
  ) {
28
- const mergedConfig = Object.assign(defaultConfig, config);
28
+ const mergedConfig: DefaultConfig = {
29
+ ...defaultConfig,
30
+ ...config,
31
+ container: { ...defaultConfig.container, ...config.container },
32
+ };
29
33
  const _ast = generateAST(html);
30
34
  const rectifyConfig: RectifyConfig = {
31
35
  extraComponentsMap: config.extraComponentsMap,
@@ -27,6 +27,7 @@ export type ExtraComponentConfig = {
27
27
  export type RendererConfig = {
28
28
  container: {
29
29
  type: string;
30
+ class?: string;
30
31
  };
31
32
  extraComponentsMap: Record<string, ExtraComponentConfig>;
32
33
  renderAnyway: boolean;
@@ -113,5 +114,13 @@ export function renderer(
113
114
  const rendered = _render(createElement, ast);
114
115
  const children = flattenChildren(rendered);
115
116
 
116
- return createElement(config.container.type, context?.data || {}, children);
117
+ const containerProps: Record<string, unknown> = { ...(context?.data || {}) };
118
+
119
+ if (config.container.class) {
120
+ containerProps.class = containerProps.class
121
+ ? [containerProps.class, config.container.class]
122
+ : config.container.class;
123
+ }
124
+
125
+ return createElement(config.container.type, containerProps, children);
117
126
  }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { getVisibleListingFilters } from "./listingFilters";
4
+
5
+ describe("getVisibleListingFilters", () => {
6
+ const filters = [
7
+ { code: "manufacturer" },
8
+ { code: "categories" },
9
+ { code: "price" },
10
+ ];
11
+
12
+ it("should keep the categories filter on a search listing", () => {
13
+ expect(
14
+ getVisibleListingFilters(filters, { isProductSearch: true }),
15
+ ).toStrictEqual(filters);
16
+ });
17
+
18
+ it("should drop the categories filter on a non-search listing", () => {
19
+ expect(
20
+ getVisibleListingFilters(filters, { isProductSearch: false }),
21
+ ).toStrictEqual([{ code: "manufacturer" }, { code: "price" }]);
22
+ });
23
+
24
+ it("should leave listings without a categories filter untouched", () => {
25
+ const withoutCategories = [{ code: "manufacturer" }, { code: "rating" }];
26
+ expect(
27
+ getVisibleListingFilters(withoutCategories, { isProductSearch: false }),
28
+ ).toStrictEqual(withoutCategories);
29
+ });
30
+
31
+ it("should return an empty array for missing filters", () => {
32
+ expect(
33
+ getVisibleListingFilters(undefined, { isProductSearch: true }),
34
+ ).toStrictEqual([]);
35
+ expect(
36
+ getVisibleListingFilters(null, { isProductSearch: false }),
37
+ ).toStrictEqual([]);
38
+ });
39
+ });
@@ -0,0 +1,19 @@
1
+ import { CATEGORY_AGGREGATION_NAME } from "@shopware/helpers";
2
+
3
+ /**
4
+ * Narrows the listing filters to the ones the current listing can act on.
5
+ *
6
+ * The category selection is only written to the `categories` URL param and to
7
+ * the criteria `post-filter` on search listings. A category listing that
8
+ * happens to request the category aggregations would otherwise render a
9
+ * checkbox that the next query sync resets and that never changes the result
10
+ * set, so the filter is dropped there.
11
+ */
12
+ export const getVisibleListingFilters = <T extends { code: string }>(
13
+ filters: T[] | undefined | null,
14
+ { isProductSearch }: { isProductSearch: boolean },
15
+ ): T[] => {
16
+ if (!filters) return [];
17
+ if (isProductSearch) return filters;
18
+ return filters.filter((filter) => filter.code !== CATEGORY_AGGREGATION_NAME);
19
+ };