@shopware/cms-base-layer 4.0.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 (75) hide show
  1. package/README.md +58 -43
  2. package/app/components/SwCategoryNavigationLink.vue +4 -5
  3. package/app/components/SwMedia3D.vue +8 -3
  4. package/app/components/SwProductAddToCart.vue +27 -4
  5. package/app/components/SwProductCard.vue +26 -4
  6. package/app/components/SwProductCardDetails.vue +9 -2
  7. package/app/components/SwProductListingFilter.vue +1 -1
  8. package/app/components/SwProductListingFilters.vue +10 -222
  9. package/app/components/SwProductListingFiltersHorizontal.vue +15 -202
  10. package/app/components/public/cms/CmsBlockSpatialViewer.vue +7 -7
  11. package/app/components/public/cms/CmsGenericBlock.vue +6 -10
  12. package/app/components/public/cms/CmsGenericElement.md +11 -5
  13. package/app/components/public/cms/CmsGenericElement.vue +17 -11
  14. package/app/components/public/cms/block/CmsBlockAppRenderer.vue +48 -0
  15. package/app/components/public/cms/block/CmsBlockCategoryHeading.vue +19 -0
  16. package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +3 -2
  17. package/app/components/public/cms/block/CmsBlockCenterText.vue +5 -4
  18. package/app/components/public/cms/block/CmsBlockCrossSelling.vue +3 -2
  19. package/app/components/public/cms/block/CmsBlockCustomForm.vue +3 -2
  20. package/app/components/public/cms/block/CmsBlockForm.vue +3 -2
  21. package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -3
  22. package/app/components/public/cms/block/CmsBlockImage.vue +3 -2
  23. package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -4
  24. package/app/components/public/cms/block/CmsBlockImageCover.vue +3 -2
  25. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -5
  26. package/app/components/public/cms/block/CmsBlockImageGallery.vue +3 -2
  27. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +3 -2
  28. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +5 -4
  29. package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +5 -4
  30. package/app/components/public/cms/block/CmsBlockImageSlider.vue +3 -2
  31. package/app/components/public/cms/block/CmsBlockImageText.vue +4 -3
  32. package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +8 -7
  33. package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -3
  34. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +39 -16
  35. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +8 -7
  36. package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +5 -4
  37. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +5 -4
  38. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +4 -3
  39. package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +3 -2
  40. package/app/components/public/cms/block/CmsBlockProductHeading.vue +4 -3
  41. package/app/components/public/cms/block/CmsBlockProductListing.vue +3 -2
  42. package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +5 -4
  43. package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +7 -5
  44. package/app/components/public/cms/block/CmsBlockText.vue +3 -2
  45. package/app/components/public/cms/block/CmsBlockTextHero.vue +3 -2
  46. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +3 -2
  47. package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +5 -4
  48. package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -3
  49. package/app/components/public/cms/block/CmsBlockVideo.vue +19 -0
  50. package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +3 -2
  51. package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +3 -2
  52. package/app/components/public/cms/element/CmsElementCategoryName.md +8 -0
  53. package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
  54. package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
  55. package/app/components/public/cms/element/CmsElementText.vue +11 -10
  56. package/app/components/public/cms/element/CmsElementVideo.md +27 -0
  57. package/app/components/public/cms/element/CmsElementVideo.vue +193 -0
  58. package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -4
  59. package/app/composables/useListingFilters.ts +189 -0
  60. package/app/composables/useSelectedListingFilters.ts +46 -0
  61. package/app/helpers/cms/getAppRendererLayout.test.ts +93 -0
  62. package/app/helpers/cms/getAppRendererLayout.ts +36 -0
  63. package/app/helpers/cms/getCategoryNameContent.test.ts +57 -0
  64. package/app/helpers/cms/getCategoryNameContent.ts +15 -0
  65. package/app/helpers/cms/getTextElementContent.test.ts +79 -0
  66. package/app/helpers/cms/getTextElementContent.ts +37 -0
  67. package/app/helpers/cms/getVideoElementOptions.test.ts +384 -0
  68. package/app/helpers/cms/getVideoElementOptions.ts +142 -0
  69. package/app/helpers/html-to-vue/renderToHtml.test.ts +16 -0
  70. package/app/helpers/html-to-vue/renderer.ts +3 -3
  71. package/app/helpers/product/shouldShowBuyButton.test.ts +21 -0
  72. package/app/helpers/product/shouldShowBuyButton.ts +6 -0
  73. package/app/utils/{useSelectedListingFilters.test.ts → listingFilterState.test.ts} +1 -1
  74. package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +0 -35
  75. package/package.json +10 -10
@@ -0,0 +1,93 @@
1
+ import type { CmsBlockAppRenderer } from "@shopware/composables";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { getAppRendererLayout } from "./getAppRendererLayout";
5
+
6
+ const block = (
7
+ customFields: CmsBlockAppRenderer["customFields"],
8
+ slots: string[] = [],
9
+ ) =>
10
+ ({
11
+ type: "app-renderer",
12
+ customFields,
13
+ slots: slots.map((slot) => ({ id: slot, slot, type: slot.split("-")[0] })),
14
+ }) as unknown as CmsBlockAppRenderer;
15
+
16
+ describe("getAppRendererLayout", () => {
17
+ it("reads the app block name and grid", () => {
18
+ const layout = getAppRendererLayout(
19
+ block({
20
+ appBlockName: "swag-two-columns",
21
+ slotLayout: { grid: "auto / auto auto" },
22
+ }),
23
+ );
24
+
25
+ expect(layout.appBlockName).toBe("swag-two-columns");
26
+ expect(layout.grid).toBe("auto / auto auto");
27
+ });
28
+
29
+ it.each([
30
+ ["swag-two-columns", "CmsBlockAppRendererSwagTwoColumns"],
31
+ ["SwagTwoColumns", "CmsBlockAppRendererSwagTwoColumns"],
32
+ ["swag_two_columns", "CmsBlockAppRendererSwagTwoColumns"],
33
+ ])(
34
+ "derives the override component name from %s",
35
+ (appBlockName, expected) => {
36
+ expect(getAppRendererLayout(block({ appBlockName })).componentName).toBe(
37
+ expected,
38
+ );
39
+ },
40
+ );
41
+
42
+ it("has no override component and no grid without custom fields", () => {
43
+ const layout = getAppRendererLayout(block(null));
44
+
45
+ expect(layout.appBlockName).toBe("");
46
+ expect(layout.componentName).toBeUndefined();
47
+ expect(layout.grid).toBeUndefined();
48
+ });
49
+
50
+ it("restores the declared order from the name-sorted Store API order", () => {
51
+ const layout = getAppRendererLayout(
52
+ block({ appBlockName: "swag" }, [
53
+ "image-1",
54
+ "image-3",
55
+ "text-0",
56
+ "text-10",
57
+ "text-2",
58
+ ]),
59
+ );
60
+
61
+ expect(layout.slots.map((slot) => slot.slot)).toEqual([
62
+ "text-0",
63
+ "image-1",
64
+ "text-2",
65
+ "image-3",
66
+ "text-10",
67
+ ]);
68
+ });
69
+
70
+ it("keeps slots without an index after the indexed ones, in their order", () => {
71
+ const layout = getAppRendererLayout(
72
+ block({ appBlockName: "swag" }, ["content", "text-1", "left", "text-0"]),
73
+ );
74
+
75
+ expect(layout.slots.map((slot) => slot.slot)).toEqual([
76
+ "text-0",
77
+ "text-1",
78
+ "content",
79
+ "left",
80
+ ]);
81
+ });
82
+
83
+ it("does not reorder the block's own slots", () => {
84
+ const content = block({ appBlockName: "swag" }, ["text-1", "text-0"]);
85
+
86
+ getAppRendererLayout(content);
87
+
88
+ expect(content.slots.map((slot) => slot.slot)).toEqual([
89
+ "text-1",
90
+ "text-0",
91
+ ]);
92
+ });
93
+ });
@@ -0,0 +1,36 @@
1
+ import type { CmsBlockAppRenderer } from "@shopware/composables";
2
+ import { pascalCase } from "scule";
3
+
4
+ type AppRendererSlot = CmsBlockAppRenderer["slots"][number];
5
+
6
+ export type AppRendererLayout = {
7
+ appBlockName: string;
8
+ componentName?: string;
9
+ grid?: string;
10
+ slots: AppRendererSlot[];
11
+ };
12
+
13
+ const SLOT_INDEX = /-(\d+)$/;
14
+
15
+ function getSlotIndex(slot: AppRendererSlot): number {
16
+ const match = SLOT_INDEX.exec(slot.slot ?? "");
17
+
18
+ return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
19
+ }
20
+
21
+ export function getAppRendererLayout(
22
+ block: CmsBlockAppRenderer,
23
+ ): AppRendererLayout {
24
+ const appBlockName = block.customFields?.appBlockName ?? "";
25
+
26
+ return {
27
+ appBlockName,
28
+ componentName: appBlockName
29
+ ? pascalCase(`CmsBlockAppRenderer-${appBlockName}`)
30
+ : undefined,
31
+ grid: block.customFields?.slotLayout?.grid || undefined,
32
+ slots: [...(block.slots ?? [])].sort(
33
+ (a, b) => getSlotIndex(a) - getSlotIndex(b),
34
+ ),
35
+ };
36
+ }
@@ -0,0 +1,57 @@
1
+ import type { CmsElementCategoryName } from "@shopware/composables";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { getCategoryNameContent } from "./getCategoryNameContent";
5
+
6
+ const element = (source: "static" | "mapped", content: string | null) =>
7
+ ({
8
+ type: "category-name",
9
+ config: {
10
+ content: {
11
+ source,
12
+ value: source === "mapped" ? "category.name" : "<p>static</p>",
13
+ },
14
+ },
15
+ data: { content, apiAlias: "cms_text" },
16
+ }) as unknown as CmsElementCategoryName;
17
+
18
+ describe("getCategoryNameContent", () => {
19
+ it("wraps mapped content in the page headline", () => {
20
+ expect(getCategoryNameContent(element("mapped", "Clothing"))).toBe(
21
+ '<h1 class="cms-element-category-name-headline">Clothing</h1>',
22
+ );
23
+ });
24
+
25
+ it("keeps entities the backend sanitizer produced", () => {
26
+ expect(getCategoryNameContent(element("mapped", "Shoes &amp; Bags"))).toBe(
27
+ '<h1 class="cms-element-category-name-headline">Shoes &amp; Bags</h1>',
28
+ );
29
+ });
30
+
31
+ it("returns static content as authored", () => {
32
+ expect(
33
+ getCategoryNameContent(element("static", "<h2>Our <b>range</b></h2>")),
34
+ ).toBe("<h2>Our <b>range</b></h2>");
35
+ });
36
+
37
+ it("renders no empty headline when the mapped value did not resolve", () => {
38
+ expect(getCategoryNameContent(element("mapped", null))).toBe("");
39
+ expect(getCategoryNameContent(element("mapped", ""))).toBe("");
40
+ });
41
+
42
+ it("renders no headline around a mapping path the backend returned", () => {
43
+ const leakedPath = {
44
+ type: "category-name",
45
+ config: {
46
+ content: { source: "mapped", value: "category.customFields" },
47
+ },
48
+ data: { content: "category.customFields", apiAlias: "cms_text" },
49
+ } as unknown as CmsElementCategoryName;
50
+
51
+ expect(getCategoryNameContent(leakedPath)).toBe("");
52
+ });
53
+
54
+ it("returns nothing for static content that did not resolve", () => {
55
+ expect(getCategoryNameContent(element("static", null))).toBe("");
56
+ });
57
+ });
@@ -0,0 +1,15 @@
1
+ import type { CmsElementCategoryName } from "@shopware/composables";
2
+
3
+ import { getTextElementContent } from "./getTextElementContent";
4
+
5
+ export function getCategoryNameContent(
6
+ element: CmsElementCategoryName,
7
+ ): string {
8
+ const content = getTextElementContent(element);
9
+
10
+ if (!content || element.config?.content?.source !== "mapped") {
11
+ return content;
12
+ }
13
+
14
+ return `<h1 class="cms-element-category-name-headline">${content}</h1>`;
15
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { getTextElementContent } from "./getTextElementContent";
4
+
5
+ const element = (
6
+ source: "static" | "mapped",
7
+ value: string,
8
+ data: { content: string | null } | null,
9
+ ) => ({ config: { content: { source, value } }, data });
10
+
11
+ describe("getTextElementContent", () => {
12
+ it("renders the content the backend resolved", () => {
13
+ expect(
14
+ getTextElementContent(
15
+ element("static", "<p>{{ category.name }}</p>", {
16
+ content: "<p>Clothing</p>",
17
+ }),
18
+ ),
19
+ ).toBe("<p>Clothing</p>");
20
+ expect(
21
+ getTextElementContent(
22
+ element("mapped", "category.name", { content: "Clothing" }),
23
+ ),
24
+ ).toBe("Clothing");
25
+ });
26
+
27
+ it("does not fall back to the unsanitized config when the backend sanitized the content away", () => {
28
+ expect(
29
+ getTextElementContent(
30
+ element("static", "<script>alert(1)</script>", { content: "" }),
31
+ ),
32
+ ).toBe("");
33
+ });
34
+
35
+ it("does not render a placeholder that resolved to nothing", () => {
36
+ expect(
37
+ getTextElementContent(
38
+ element("static", "{{ category.description }}", { content: "" }),
39
+ ),
40
+ ).toBe("");
41
+ expect(
42
+ getTextElementContent(
43
+ element("static", "{{ category.description }}", { content: null }),
44
+ ),
45
+ ).toBe("");
46
+ });
47
+
48
+ it("does not render a mapping path", () => {
49
+ expect(
50
+ getTextElementContent(
51
+ element("mapped", "category.name", { content: null }),
52
+ ),
53
+ ).toBe("");
54
+ expect(
55
+ getTextElementContent(
56
+ element("mapped", "category.customFields", {
57
+ content: "category.customFields",
58
+ }),
59
+ ),
60
+ ).toBe("");
61
+ });
62
+
63
+ it("renders static config for an element without data", () => {
64
+ expect(getTextElementContent(element("static", "<p>Hi</p>", null))).toBe(
65
+ "<p>Hi</p>",
66
+ );
67
+ expect(
68
+ getTextElementContent(element("mapped", "category.name", null)),
69
+ ).toBe("");
70
+ });
71
+
72
+ it("renders nothing for a missing element or config", () => {
73
+ expect(getTextElementContent(undefined)).toBe("");
74
+ expect(getTextElementContent({ config: null, data: null })).toBe("");
75
+ expect(getTextElementContent({ data: { content: "<p>x</p>" } })).toBe(
76
+ "<p>x</p>",
77
+ );
78
+ });
79
+ });
@@ -0,0 +1,37 @@
1
+ type TextElementLike = {
2
+ config?: {
3
+ content?: { source?: string; value?: unknown } | null;
4
+ } | null;
5
+ data?: { content?: string | null } | null;
6
+ };
7
+
8
+ /**
9
+ * The HTML a text-like element (`text`, `product-name`, `category-name`)
10
+ * renders.
11
+ *
12
+ * Once the Store API resolved the slot, `data.content` is the only source: the
13
+ * backend sanitized it, `config.content.value` is the raw input. The config is
14
+ * read only for an element without `data`, which the API never sends but a
15
+ * hand-built element may.
16
+ */
17
+ export function getTextElementContent(
18
+ element: TextElementLike | null | undefined,
19
+ ): string {
20
+ const config = element?.config?.content;
21
+
22
+ if (!element?.data) {
23
+ return config?.source !== "mapped" && typeof config?.value === "string"
24
+ ? config.value
25
+ : "";
26
+ }
27
+
28
+ const content = element.data.content ?? "";
29
+
30
+ // The backend answers a mapping to a value that is not a string (an entity,
31
+ // an array) with the mapping path itself, e.g. `category.customFields`.
32
+ if (config?.source === "mapped" && content === config.value) {
33
+ return "";
34
+ }
35
+
36
+ return content;
37
+ }
@@ -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
+ });