@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.
- package/README.md +64 -23
- package/app/app.config.ts +3 -3
- package/app/assets/css/rich-text.css +80 -0
- package/app/components/SwContactForm.vue +2 -1
- package/app/components/SwFilterChips.vue +29 -7
- package/app/components/SwNewsletterForm.vue +2 -1
- package/app/components/SwProductCardImage.vue +5 -1
- package/app/components/SwProductListingFilter.vue +5 -0
- package/app/components/SwProductListingFilters.vue +40 -4
- package/app/components/SwProductListingFiltersHorizontal.vue +37 -2
- package/app/components/SwStockInfo.vue +5 -2
- package/app/components/SwVariantConfigurator.vue +8 -3
- package/app/components/listing-filters/SwFilterCategories.vue +158 -0
- package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
- package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
- package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
- package/app/components/public/cms/element/CmsElementImage.vue +41 -1
- package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +2 -2
- package/app/components/public/cms/element/CmsElementText.md +77 -0
- package/app/components/public/cms/element/CmsElementText.vue +5 -32
- package/app/helpers/html-to-vue/renderToHtml.test.ts +55 -0
- package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
- package/app/helpers/html-to-vue/renderer.ts +10 -1
- package/app/utils/listingFilters.test.ts +39 -0
- package/app/utils/listingFilters.ts +19 -0
- package/app/utils/useSelectedListingFilters.test.ts +13 -3
- package/app/utils/useSelectedListingFilters.ts +7 -1
- package/component-dirs.json +20 -0
- package/index.d.ts +2 -2
- package/nuxt.config.ts +11 -28
- package/package.json +20 -23
- package/dist/index.d.mts +0 -5
- package/dist/index.d.ts +0 -5
- package/dist/index.mjs +0 -31
- 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 }}
|
|
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 }}
|
|
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 {
|
|
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
|
|
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 }}
|
|
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(() =>
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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='
|
|
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 =
|
|
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
|
-
|
|
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
|
+
};
|