@shopware/cms-base-layer 3.1.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +79 -23
  2. package/app/app.config.ts +3 -3
  3. package/app/assets/css/rich-text.css +80 -0
  4. package/app/components/SwCategoryNavigationLink.vue +4 -5
  5. package/app/components/SwContactForm.vue +2 -1
  6. package/app/components/SwFilterChips.vue +29 -7
  7. package/app/components/SwMedia3D.vue +8 -3
  8. package/app/components/SwNewsletterForm.vue +2 -1
  9. package/app/components/SwProductAddToCart.vue +27 -4
  10. package/app/components/SwProductCard.vue +26 -4
  11. package/app/components/SwProductCardDetails.vue +9 -2
  12. package/app/components/SwProductCardImage.vue +5 -1
  13. package/app/components/SwProductListingFilter.vue +6 -1
  14. package/app/components/SwProductListingFilters.vue +13 -189
  15. package/app/components/SwProductListingFiltersHorizontal.vue +20 -172
  16. package/app/components/SwStockInfo.vue +5 -2
  17. package/app/components/SwVariantConfigurator.vue +8 -3
  18. package/app/components/listing-filters/SwFilterCategories.vue +158 -0
  19. package/app/components/public/cms/CmsBlockSpatialViewer.vue +7 -7
  20. package/app/components/public/cms/CmsGenericBlock.vue +6 -10
  21. package/app/components/public/cms/CmsGenericElement.md +11 -5
  22. package/app/components/public/cms/CmsGenericElement.vue +17 -11
  23. package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
  24. package/app/components/public/cms/block/CmsBlockAppRenderer.vue +48 -0
  25. package/app/components/public/cms/block/CmsBlockCategoryHeading.vue +19 -0
  26. package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +3 -2
  27. package/app/components/public/cms/block/CmsBlockCenterText.vue +5 -4
  28. package/app/components/public/cms/block/CmsBlockCrossSelling.vue +3 -2
  29. package/app/components/public/cms/block/CmsBlockCustomForm.vue +3 -2
  30. package/app/components/public/cms/block/CmsBlockForm.vue +3 -2
  31. package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -3
  32. package/app/components/public/cms/block/CmsBlockImage.vue +3 -2
  33. package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -4
  34. package/app/components/public/cms/block/CmsBlockImageCover.vue +3 -2
  35. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -5
  36. package/app/components/public/cms/block/CmsBlockImageGallery.vue +3 -2
  37. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +3 -2
  38. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +5 -4
  39. package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +5 -4
  40. package/app/components/public/cms/block/CmsBlockImageSlider.vue +3 -2
  41. package/app/components/public/cms/block/CmsBlockImageText.vue +4 -3
  42. package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +8 -7
  43. package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -3
  44. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +39 -16
  45. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +8 -7
  46. package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +5 -4
  47. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +5 -4
  48. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +4 -3
  49. package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +3 -2
  50. package/app/components/public/cms/block/CmsBlockProductHeading.vue +4 -3
  51. package/app/components/public/cms/block/CmsBlockProductListing.vue +3 -2
  52. package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +5 -4
  53. package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +7 -5
  54. package/app/components/public/cms/block/CmsBlockText.vue +3 -2
  55. package/app/components/public/cms/block/CmsBlockTextHero.vue +3 -2
  56. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +3 -2
  57. package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +5 -4
  58. package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -3
  59. package/app/components/public/cms/block/CmsBlockVideo.vue +19 -0
  60. package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +3 -2
  61. package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +3 -2
  62. package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
  63. package/app/components/public/cms/element/CmsElementCategoryName.md +8 -0
  64. package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
  65. package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
  66. package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
  67. package/app/components/public/cms/element/CmsElementImage.vue +41 -1
  68. package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
  69. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +2 -2
  70. package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
  71. package/app/components/public/cms/element/CmsElementText.md +77 -0
  72. package/app/components/public/cms/element/CmsElementText.vue +15 -41
  73. package/app/components/public/cms/element/CmsElementVideo.md +27 -0
  74. package/app/components/public/cms/element/CmsElementVideo.vue +193 -0
  75. package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -4
  76. package/app/composables/useListingFilters.ts +189 -0
  77. package/app/composables/useSelectedListingFilters.ts +46 -0
  78. package/app/helpers/cms/getAppRendererLayout.test.ts +93 -0
  79. package/app/helpers/cms/getAppRendererLayout.ts +36 -0
  80. package/app/helpers/cms/getCategoryNameContent.test.ts +57 -0
  81. package/app/helpers/cms/getCategoryNameContent.ts +15 -0
  82. package/app/helpers/cms/getTextElementContent.test.ts +79 -0
  83. package/app/helpers/cms/getTextElementContent.ts +37 -0
  84. package/app/helpers/cms/getVideoElementOptions.test.ts +384 -0
  85. package/app/helpers/cms/getVideoElementOptions.ts +142 -0
  86. package/app/helpers/html-to-vue/renderToHtml.test.ts +71 -0
  87. package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
  88. package/app/helpers/html-to-vue/renderer.ts +13 -4
  89. package/app/helpers/product/shouldShowBuyButton.test.ts +21 -0
  90. package/app/helpers/product/shouldShowBuyButton.ts +6 -0
  91. package/app/utils/{useSelectedListingFilters.test.ts → listingFilterState.test.ts} +14 -4
  92. package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +4 -33
  93. package/app/utils/listingFilters.test.ts +39 -0
  94. package/app/utils/listingFilters.ts +19 -0
  95. package/component-dirs.json +20 -0
  96. package/index.d.ts +2 -2
  97. package/nuxt.config.ts +11 -28
  98. package/package.json +23 -26
  99. package/dist/index.d.mts +0 -5
  100. package/dist/index.d.ts +0 -5
  101. package/dist/index.mjs +0 -31
  102. package/index.cjs +0 -7
@@ -0,0 +1,384 @@
1
+ import type { CmsElementVideo } from "@shopware/composables";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import {
5
+ getVideoAttributes,
6
+ getVideoElementOptions,
7
+ getVideoToggleAttributes,
8
+ } from "./getVideoElementOptions";
9
+ import type { VideoElementOptions } from "./getVideoElementOptions";
10
+
11
+ type Config = Partial<Record<keyof CmsElementVideo["config"], unknown>>;
12
+
13
+ const media = {
14
+ url: "https://shop.test/media/intro.mp4",
15
+ mimeType: "video/mp4",
16
+ alt: "Alt",
17
+ title: "Title",
18
+ translated: { alt: "Translated alt", title: "Translated title" },
19
+ extensions: {
20
+ videoCoverMedia: { url: "https://shop.test/media/intro-cover.jpg" },
21
+ },
22
+ };
23
+
24
+ const element = (
25
+ config: Config = {},
26
+ data: Partial<CmsElementVideo["data"]> = {},
27
+ ) =>
28
+ ({
29
+ type: "video",
30
+ config: Object.fromEntries(
31
+ Object.entries(config).map(([key, value]) => [
32
+ key,
33
+ { source: "static", value },
34
+ ]),
35
+ ),
36
+ data: {
37
+ apiAlias: "cms_video",
38
+ media,
39
+ ariaLabel: "ariaLabel" in config ? String(config.ariaLabel ?? "") : null,
40
+ ...data,
41
+ },
42
+ }) as unknown as CmsElementVideo;
43
+
44
+ const withMedia = (overrides: Record<string, unknown>) => ({
45
+ media: { ...media, ...overrides } as CmsElementVideo["data"]["media"],
46
+ });
47
+
48
+ describe("getVideoElementOptions", () => {
49
+ describe("media", () => {
50
+ it("plays the resolved media", () => {
51
+ const options = getVideoElementOptions(element());
52
+
53
+ expect(options.src).toBe("https://shop.test/media/intro.mp4");
54
+ expect(options.mimeType).toBe("video/mp4");
55
+ expect(options.preload).toBe("auto");
56
+ });
57
+
58
+ it("has no source when no media resolved", () => {
59
+ expect(
60
+ getVideoElementOptions(element({}, { media: null })).src,
61
+ ).toBeUndefined();
62
+ });
63
+ });
64
+
65
+ describe("displayMode", () => {
66
+ it.each(["standard", "stretch", "cover"])("accepts %s", (displayMode) => {
67
+ expect(getVideoElementOptions(element({ displayMode })).displayMode).toBe(
68
+ displayMode,
69
+ );
70
+ });
71
+
72
+ it("falls back to standard for a missing or unknown value", () => {
73
+ expect(getVideoElementOptions(element()).displayMode).toBe("standard");
74
+ expect(
75
+ getVideoElementOptions(element({ displayMode: "contain" })).displayMode,
76
+ ).toBe("standard");
77
+ });
78
+
79
+ it("applies the minimum height to cover only", () => {
80
+ expect(
81
+ getVideoElementOptions(
82
+ element({ displayMode: "cover", minHeight: "400px" }),
83
+ ).minHeight,
84
+ ).toBe("400px");
85
+ expect(
86
+ getVideoElementOptions(
87
+ element({ displayMode: "standard", minHeight: "400px" }),
88
+ ).minHeight,
89
+ ).toBeUndefined();
90
+ });
91
+ });
92
+
93
+ describe("alignment", () => {
94
+ it("reads both alignments", () => {
95
+ const options = getVideoElementOptions(
96
+ element({ verticalAlign: "flex-end", horizontalAlign: "center" }),
97
+ );
98
+
99
+ expect(options.verticalAlign).toBe("flex-end");
100
+ expect(options.horizontalAlign).toBe("center");
101
+ });
102
+
103
+ it("ignores both alignments for cover, which fills the element", () => {
104
+ const options = getVideoElementOptions(
105
+ element({
106
+ displayMode: "cover",
107
+ verticalAlign: "flex-end",
108
+ horizontalAlign: "center",
109
+ }),
110
+ );
111
+
112
+ expect(options.verticalAlign).toBeUndefined();
113
+ expect(options.horizontalAlign).toBeUndefined();
114
+ });
115
+
116
+ it("drops an empty or unknown alignment", () => {
117
+ const options = getVideoElementOptions(
118
+ element({ verticalAlign: "", horizontalAlign: "left" }),
119
+ );
120
+
121
+ expect(options.verticalAlign).toBeUndefined();
122
+ expect(options.horizontalAlign).toBeUndefined();
123
+ });
124
+ });
125
+
126
+ describe("playback", () => {
127
+ const switchesOf = ({
128
+ muted,
129
+ loop,
130
+ playsInline,
131
+ controls,
132
+ }: VideoElementOptions) => ({ muted, loop, playsInline, controls });
133
+
134
+ it.each([
135
+ ["muted", "muted"],
136
+ ["loop", "loop"],
137
+ ["playsInline", "playsInline"],
138
+ ["showControls", "controls"],
139
+ ] as const)("maps %s to %s and nothing else", (configKey, optionKey) => {
140
+ expect(
141
+ switchesOf(getVideoElementOptions(element({ [configKey]: true }))),
142
+ ).toEqual({
143
+ muted: false,
144
+ loop: false,
145
+ playsInline: false,
146
+ controls: false,
147
+ [optionKey]: true,
148
+ });
149
+ });
150
+
151
+ it("leaves every switch off when it is not configured", () => {
152
+ expect(getVideoElementOptions(element())).toMatchObject({
153
+ autoplay: false,
154
+ muted: false,
155
+ loop: false,
156
+ playsInline: false,
157
+ controls: false,
158
+ });
159
+ });
160
+
161
+ it("mutes an autoplaying video", () => {
162
+ expect(
163
+ getVideoElementOptions(element({ autoPlay: true, muted: false })),
164
+ ).toMatchObject({ autoplay: true, muted: true });
165
+ });
166
+ });
167
+
168
+ describe("load only after confirmation", () => {
169
+ it("shows the cover, loads nothing up front and does not autoplay", () => {
170
+ expect(
171
+ getVideoElementOptions(element({ showCover: true, autoPlay: true })),
172
+ ).toMatchObject({
173
+ poster: "https://shop.test/media/intro-cover.jpg",
174
+ preload: "none",
175
+ autoplay: false,
176
+ muted: false,
177
+ });
178
+ });
179
+
180
+ it("waits for the visitor even when the video has no cover", () => {
181
+ const options = getVideoElementOptions(
182
+ element({ showCover: true }, withMedia({ extensions: {} })),
183
+ );
184
+
185
+ expect(options.poster).toBeUndefined();
186
+ expect(options.preload).toBe("none");
187
+ });
188
+
189
+ it("does not use the cover without the option", () => {
190
+ expect(getVideoElementOptions(element()).poster).toBeUndefined();
191
+ });
192
+
193
+ it("reserves a 16:9 box for a stretched video without a cover", () => {
194
+ expect(
195
+ getVideoElementOptions(
196
+ element(
197
+ { showCover: true, displayMode: "stretch" },
198
+ withMedia({ extensions: {} }),
199
+ ),
200
+ ).placeholderAspectRatio,
201
+ ).toBe("auto 16 / 9");
202
+ });
203
+
204
+ it.each([
205
+ ["the cover sizes it", { showCover: true, displayMode: "stretch" }, {}],
206
+ [
207
+ "it is a standard video",
208
+ { showCover: true, displayMode: "standard" },
209
+ withMedia({ extensions: {} }),
210
+ ],
211
+ [
212
+ "it covers the element",
213
+ { showCover: true, displayMode: "cover" },
214
+ withMedia({ extensions: {} }),
215
+ ],
216
+ [
217
+ "its metadata preloads",
218
+ { displayMode: "stretch" },
219
+ withMedia({ extensions: {} }),
220
+ ],
221
+ ])("reserves no box when %s", (_, config, data) => {
222
+ expect(
223
+ getVideoElementOptions(element(config, data)).placeholderAspectRatio,
224
+ ).toBeUndefined();
225
+ });
226
+ });
227
+
228
+ describe("accessible name", () => {
229
+ it("uses the screen reader title", () => {
230
+ const options = getVideoElementOptions(
231
+ element({ ariaLabel: " Product demo " }),
232
+ );
233
+
234
+ expect(options.ariaLabel).toBe("Product demo");
235
+ expect(options.title).toBe("Product demo");
236
+ });
237
+
238
+ it("falls back to the media alt text and title", () => {
239
+ const options = getVideoElementOptions(element({ ariaLabel: null }));
240
+
241
+ expect(options.ariaLabel).toBe("Translated alt");
242
+ expect(options.title).toBe("Translated title");
243
+ });
244
+
245
+ it("trims the media alt text and title", () => {
246
+ const options = getVideoElementOptions(
247
+ element(
248
+ {},
249
+ withMedia({ translated: { alt: " Alt ", title: " Title " } }),
250
+ ),
251
+ );
252
+
253
+ expect(options.ariaLabel).toBe("Alt");
254
+ expect(options.title).toBe("Title");
255
+ });
256
+
257
+ it("has no name when nothing provides one", () => {
258
+ const options = getVideoElementOptions(
259
+ element(
260
+ { ariaLabel: " " },
261
+ withMedia({
262
+ alt: "",
263
+ title: "",
264
+ translated: { alt: " ", title: "" },
265
+ }),
266
+ ),
267
+ );
268
+
269
+ expect(options.ariaLabel).toBeUndefined();
270
+ expect(options.title).toBeUndefined();
271
+ });
272
+
273
+ it("ignores a mapped screen reader title, which arrives as the mapping path", () => {
274
+ const mapped = element({}, { ariaLabel: "media.title" });
275
+ mapped.config.ariaLabel = { source: "mapped", value: "media.title" };
276
+
277
+ expect(getVideoElementOptions(mapped).ariaLabel).toBe("Translated alt");
278
+ });
279
+ });
280
+ });
281
+
282
+ describe("getVideoAttributes", () => {
283
+ it("renders no playsinline attribute when inline playback is off", () => {
284
+ expect(
285
+ getVideoAttributes(getVideoElementOptions(element())).playsinline,
286
+ ).toBeUndefined();
287
+ expect(
288
+ getVideoAttributes(getVideoElementOptions(element({ playsInline: true })))
289
+ .playsinline,
290
+ ).toBe(true);
291
+ });
292
+
293
+ it("passes the options on to the video", () => {
294
+ expect(
295
+ getVideoAttributes(
296
+ getVideoElementOptions(
297
+ element({ autoPlay: true, loop: true, ariaLabel: "Product demo" }),
298
+ ),
299
+ ),
300
+ ).toEqual({
301
+ preload: "auto",
302
+ poster: undefined,
303
+ autoplay: true,
304
+ muted: true,
305
+ loop: true,
306
+ playsinline: undefined,
307
+ controls: false,
308
+ "aria-label": "Product demo",
309
+ title: "Product demo",
310
+ });
311
+ });
312
+ });
313
+
314
+ describe("getVideoToggleAttributes", () => {
315
+ const labels = {
316
+ playLabel: "Play video",
317
+ pauseLabel: "Pause video",
318
+ loadError: "The video could not be loaded.",
319
+ };
320
+ const state = { isPlaying: false, hasFailed: false, videoId: "video-1" };
321
+ const unnamed = getVideoElementOptions(
322
+ element(
323
+ {},
324
+ withMedia({ alt: "", title: "", translated: { alt: "", title: "" } }),
325
+ ),
326
+ );
327
+
328
+ it("adds nothing when the video shows its controls", () => {
329
+ expect(
330
+ getVideoToggleAttributes(
331
+ getVideoElementOptions(element({ showControls: true })),
332
+ state,
333
+ labels,
334
+ ),
335
+ ).toEqual({});
336
+ });
337
+
338
+ it("names the button after what it does", () => {
339
+ expect(getVideoToggleAttributes(unnamed, state, labels)).toEqual({
340
+ role: "button",
341
+ tabindex: 0,
342
+ "aria-label": "Play video",
343
+ "aria-describedby": undefined,
344
+ "aria-disabled": undefined,
345
+ title: "Play video",
346
+ });
347
+ expect(
348
+ getVideoToggleAttributes(unnamed, { ...state, isPlaying: true }, labels)[
349
+ "aria-label"
350
+ ],
351
+ ).toBe("Pause video");
352
+ });
353
+
354
+ it("keeps the action in the name of a named video and describes it by the video", () => {
355
+ const named = getVideoElementOptions(
356
+ element({ ariaLabel: "Product demo" }),
357
+ );
358
+
359
+ expect(
360
+ getVideoToggleAttributes(named, { ...state, isPlaying: true }, labels),
361
+ ).toMatchObject({
362
+ "aria-label": "Pause video",
363
+ "aria-describedby": "video-1",
364
+ title: "Product demo",
365
+ });
366
+ });
367
+
368
+ it("reports a video that failed to load and stays focusable", () => {
369
+ expect(
370
+ getVideoToggleAttributes(
371
+ getVideoElementOptions(element({ ariaLabel: "Product demo" })),
372
+ { ...state, hasFailed: true },
373
+ labels,
374
+ ),
375
+ ).toEqual({
376
+ role: "button",
377
+ tabindex: 0,
378
+ "aria-label": "The video could not be loaded.",
379
+ "aria-describedby": "video-1",
380
+ "aria-disabled": true,
381
+ title: "The video could not be loaded.",
382
+ });
383
+ });
384
+ });
@@ -0,0 +1,142 @@
1
+ import type {
2
+ CmsElementVideo,
3
+ MediaDisplayMode,
4
+ VerticalAlign,
5
+ } from "@shopware/composables";
6
+ import { getTranslatedProperty } from "@shopware/helpers";
7
+
8
+ type Alignment = Exclude<VerticalAlign, "">;
9
+
10
+ export type VideoElementOptions = {
11
+ src?: string;
12
+ mimeType?: string;
13
+ poster?: string;
14
+ displayMode: MediaDisplayMode;
15
+ minHeight?: string;
16
+ placeholderAspectRatio?: string;
17
+ verticalAlign?: Alignment;
18
+ horizontalAlign?: Alignment;
19
+ autoplay: boolean;
20
+ muted: boolean;
21
+ loop: boolean;
22
+ playsInline: boolean;
23
+ controls: boolean;
24
+ preload: "auto" | "none";
25
+ ariaLabel?: string;
26
+ title?: string;
27
+ };
28
+
29
+ export type VideoToggleState = {
30
+ isPlaying: boolean;
31
+ hasFailed: boolean;
32
+ videoId: string;
33
+ };
34
+
35
+ export type VideoToggleLabels = {
36
+ playLabel: string;
37
+ pauseLabel: string;
38
+ loadError: string;
39
+ };
40
+
41
+ const DISPLAY_MODES: MediaDisplayMode[] = ["standard", "stretch", "cover"];
42
+ const ALIGNMENTS: Alignment[] = ["flex-start", "center", "flex-end"];
43
+
44
+ function getConfigValue<KEY extends keyof CmsElementVideo["config"]>(
45
+ element: CmsElementVideo,
46
+ key: KEY,
47
+ ): CmsElementVideo["config"][KEY]["value"] | undefined {
48
+ const config = element.config?.[key];
49
+
50
+ return config?.source === "mapped" ? undefined : config?.value;
51
+ }
52
+
53
+ function toAlignment(value: unknown): Alignment | undefined {
54
+ return ALIGNMENTS.find((alignment) => alignment === value);
55
+ }
56
+
57
+ export function getVideoElementOptions(
58
+ element: CmsElementVideo,
59
+ ): VideoElementOptions {
60
+ const media = element.data?.media ?? undefined;
61
+ const displayMode =
62
+ DISPLAY_MODES.find(
63
+ (mode) => mode === getConfigValue(element, "displayMode"),
64
+ ) ?? "standard";
65
+ const isCover = displayMode === "cover";
66
+ const showCover = !!getConfigValue(element, "showCover");
67
+ const autoplay = !showCover && !!getConfigValue(element, "autoPlay");
68
+ const poster =
69
+ (showCover && media?.extensions?.videoCoverMedia?.url) || undefined;
70
+ const label =
71
+ element.config?.ariaLabel?.source === "mapped"
72
+ ? ""
73
+ : (element.data?.ariaLabel ?? "").trim();
74
+
75
+ return {
76
+ src: media?.url || undefined,
77
+ mimeType: media?.mimeType || undefined,
78
+ poster,
79
+ displayMode,
80
+ minHeight: (isCover && getConfigValue(element, "minHeight")) || undefined,
81
+ placeholderAspectRatio:
82
+ showCover && !poster && displayMode === "stretch"
83
+ ? "auto 16 / 9"
84
+ : undefined,
85
+ verticalAlign: isCover
86
+ ? undefined
87
+ : toAlignment(getConfigValue(element, "verticalAlign")),
88
+ horizontalAlign: isCover
89
+ ? undefined
90
+ : toAlignment(getConfigValue(element, "horizontalAlign")),
91
+ autoplay,
92
+ muted: autoplay || !!getConfigValue(element, "muted"),
93
+ loop: !!getConfigValue(element, "loop"),
94
+ playsInline: !!getConfigValue(element, "playsInline"),
95
+ controls: !!getConfigValue(element, "showControls"),
96
+ preload: showCover ? "none" : "auto",
97
+ ariaLabel: label || getTranslatedProperty(media, "alt").trim() || undefined,
98
+ title: label || getTranslatedProperty(media, "title").trim() || undefined,
99
+ };
100
+ }
101
+
102
+ export function getVideoAttributes(options: VideoElementOptions) {
103
+ return {
104
+ preload: options.preload,
105
+ poster: options.poster,
106
+ autoplay: options.autoplay,
107
+ muted: options.muted,
108
+ loop: options.loop,
109
+ playsinline: options.playsInline || undefined,
110
+ controls: options.controls,
111
+ "aria-label": options.ariaLabel,
112
+ title: options.title,
113
+ };
114
+ }
115
+
116
+ function getToggleLabel(
117
+ state: VideoToggleState,
118
+ labels: VideoToggleLabels,
119
+ ): string {
120
+ if (state.hasFailed) return labels.loadError;
121
+
122
+ return state.isPlaying ? labels.pauseLabel : labels.playLabel;
123
+ }
124
+
125
+ export function getVideoToggleAttributes(
126
+ options: VideoElementOptions,
127
+ state: VideoToggleState,
128
+ labels: VideoToggleLabels,
129
+ ) {
130
+ if (options.controls) return {};
131
+
132
+ const label = getToggleLabel(state, labels);
133
+
134
+ return {
135
+ role: "button",
136
+ tabindex: 0,
137
+ "aria-label": label,
138
+ "aria-describedby": options.ariaLabel ? state.videoId : undefined,
139
+ "aria-disabled": state.hasFailed || undefined,
140
+ title: (!state.hasFailed && options.title) || label,
141
+ };
142
+ }
@@ -0,0 +1,71 @@
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("passes the attributes of a tag as flat props", () => {
48
+ const vnode = render(
49
+ '<table><tr><td colspan="2" id="total" class="sum" style="color:red">1</td></tr></table>',
50
+ );
51
+ const table = (vnode.children as { children: unknown[] }[])[0];
52
+ const row = table?.children[0] as { children: unknown[] };
53
+ const cell = row.children[0] as { props: Record<string, unknown> };
54
+
55
+ expect(cell.props).toEqual({
56
+ colspan: "2",
57
+ id: "total",
58
+ class: "sum",
59
+ style: "color:red",
60
+ });
61
+ });
62
+
63
+ it("keeps rendering the parsed children next to the container class", () => {
64
+ const vnode = render("<h2>headline</h2>", {
65
+ container: { type: "div", class: "cms-element-text" },
66
+ });
67
+
68
+ expect(Array.isArray(vnode.children)).toBe(true);
69
+ expect((vnode.children as { type: string }[])[0]?.type).toBe("h2");
70
+ });
71
+ });
@@ -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;
@@ -91,7 +92,6 @@ export function renderer(
91
92
  return config.textTransformer(node.content); // return text
92
93
  }
93
94
  if (node.type === "tag") {
94
- const transformedNode = getOptionsFromNode(node, resolveUrl);
95
95
  const children: RawChildren[] = [];
96
96
  for (const child of node.children) {
97
97
  const rendered = _render(h, child);
@@ -103,8 +103,9 @@ export function renderer(
103
103
  if (componentConfig !== undefined) {
104
104
  return componentConfig.renderer(node, children, h, context);
105
105
  }
106
- // else, create normal html element
107
- return h(node.name, transformedNode, [...children]);
106
+ // else, create normal html element; Vue 3 takes attributes as flat props
107
+ const { attrs, ...options } = getOptionsFromNode(node, resolveUrl);
108
+ return h(node.name, { ...attrs, ...options }, [...children]);
108
109
  }
109
110
  }
110
111
  return undefined;
@@ -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,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { shouldShowBuyButton } from "./shouldShowBuyButton";
4
+
5
+ describe("shouldShowBuyButton", () => {
6
+ it("shows the button for a product without variants", () => {
7
+ expect(shouldShowBuyButton({ childCount: 0 })).toBe(true);
8
+ });
9
+
10
+ it("shows the button when childCount is missing", () => {
11
+ expect(shouldShowBuyButton({})).toBe(true);
12
+ });
13
+
14
+ it("hides the button for a variant parent", () => {
15
+ expect(shouldShowBuyButton({ childCount: 2 })).toBe(false);
16
+ });
17
+
18
+ it("hides the button when there is a from price", () => {
19
+ expect(shouldShowBuyButton({ childCount: 0 }, true)).toBe(false);
20
+ });
21
+ });
@@ -0,0 +1,6 @@
1
+ export function shouldShowBuyButton(
2
+ product: { childCount?: number } | undefined,
3
+ displayFrom = false,
4
+ ): boolean {
5
+ return !displayFrom && (product?.childCount ?? 0) <= 0;
6
+ }