@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
@@ -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,10 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementProductListing } from "@shopware/composables";
3
3
  import { useCmsTranslations } from "@shopware/composables";
4
+ import { until } from "@vueuse/core";
4
5
  import { defu } from "defu";
5
6
  import { computed, ref, useTemplateRef, watch } from "vue";
7
+ import type { LocationQuery } from "vue-router";
6
8
 
7
9
  import {
10
+ firstQueryValue,
11
+ toNumber,
8
12
  useCategoryListing,
9
13
  useCmsElementConfig,
10
14
  useRoute,
@@ -58,26 +62,63 @@ const limit = ref(
58
62
  : defaultLimit,
59
63
  );
60
64
 
61
- const initalRoute = defu(route);
65
+ const initialPath = route.path;
66
+
67
+ /** Query values are strings; the Store API expects numbers and booleans. */
68
+ function buildCriteria(query: LocationQuery) {
69
+ const criteria: Record<string, unknown> = {
70
+ limit: toNumber(firstQueryValue(query.limit)) ?? defaultLimit,
71
+ p: toNumber(firstQueryValue(query.p)) ?? defaultPage,
72
+ order: firstQueryValue(query.order) ?? defaultOrder,
73
+ };
74
+
75
+ const manufacturer = firstQueryValue(query.manufacturer);
76
+ if (manufacturer) criteria.manufacturer = manufacturer;
77
+ const properties = firstQueryValue(query.properties);
78
+ if (properties) criteria.properties = properties;
79
+ const minPrice = toNumber(firstQueryValue(query["min-price"]));
80
+ if (minPrice !== undefined) criteria["min-price"] = minPrice;
81
+ const maxPrice = toNumber(firstQueryValue(query["max-price"]));
82
+ if (maxPrice !== undefined) criteria["max-price"] = maxPrice;
83
+ const rating = toNumber(firstQueryValue(query.rating));
84
+ if (rating !== undefined) criteria.rating = rating;
85
+ if (query["shipping-free"])
86
+ criteria["shipping-free"] =
87
+ firstQueryValue(query["shipping-free"]) === "true";
88
+
89
+ return criteria as unknown as operations["searchPage post /search"]["body"];
90
+ }
91
+
92
+ // The only place that fetches, so back and forward work.
62
93
  watch(
63
- () => route,
64
- (newRoute) => {
65
- if (initalRoute.path !== newRoute.path) {
66
- return;
67
- }
68
- if (Object.keys(newRoute.query).length > 0) {
69
- return;
70
- }
71
- // this fires to reset the page when query are removed/empty on client side navigation for the same page (without hard reload)
72
- changeCurrentPage(defaultPage, {
73
- limit: defaultLimit,
74
- p: defaultPage,
75
- order: defaultOrder,
76
- } as unknown as operations["searchPage post /search"]["body"]);
94
+ () => route.query,
95
+ (query) => {
96
+ // A different path mounts its own component.
97
+ if (route.path !== initialPath) return;
98
+
99
+ // The select and the skeleton count read this, so it has to follow the URL
100
+ // too, not just the products.
101
+ limit.value = toNumber(firstQueryValue(query.limit)) ?? defaultLimit;
102
+
103
+ // Caught, not discarded: the Store API drops calls often enough that an
104
+ // unhandled rejection here would be a routine occurrence.
105
+ changeCurrentPage(
106
+ toNumber(firstQueryValue(query.p)) ?? defaultPage,
107
+ buildCriteria(query),
108
+ ).catch((error) => {
109
+ console.error("Listing update failed:", error);
110
+ });
77
111
  },
78
112
  { deep: true },
79
113
  );
80
114
 
115
+ // `v-if="!loading"` unmounts the list while the watcher refetches, so scrolling
116
+ // straight after the push would target a node that is about to be detached.
117
+ async function scrollToListing() {
118
+ await until(loading).toBe(false);
119
+ productListElement.value?.scrollIntoView({ behavior: "smooth" });
120
+ }
121
+
81
122
  const changePage = async (page: number) => {
82
123
  await router.push({
83
124
  query: {
@@ -86,11 +127,7 @@ const changePage = async (page: number) => {
86
127
  limit: limit.value,
87
128
  },
88
129
  });
89
- await changeCurrentPage(
90
- page,
91
- route.query as unknown as operations["searchPage post /search"]["body"],
92
- );
93
- productListElement.value?.scrollIntoView({ behavior: "smooth" });
130
+ await scrollToListing();
94
131
  };
95
132
 
96
133
  const changeLimit = async (newLimit: number) => {
@@ -101,11 +138,7 @@ const changeLimit = async (newLimit: number) => {
101
138
  p: defaultPage,
102
139
  },
103
140
  });
104
- await changeCurrentPage(
105
- defaultPage,
106
- route.query as unknown as operations["searchPage post /search"]["body"],
107
- );
108
- productListElement.value?.scrollIntoView({ behavior: "smooth" });
141
+ await scrollToListing();
109
142
  };
110
143
 
111
144
  const isProductListing = computed(
@@ -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.
@@ -6,6 +6,7 @@ import type { CSSProperties, VNode, VNodeArrayChildren } from "vue";
6
6
 
7
7
  import { useCmsElementConfig, useUrlResolver } from "#imports";
8
8
 
9
+ import { getTextElementContent } from "../../../../helpers/cms/getTextElementContent";
9
10
  import { getOptionsFromNode } from "../../../../helpers/html-to-vue/getOptionsFromNode";
10
11
  import type { NodeObject } from "../../../../helpers/html-to-vue/getOptionsFromNode";
11
12
  import { renderHtml } from "../../../../helpers/html-to-vue/renderToHtml";
@@ -15,11 +16,9 @@ const props = defineProps<{
15
16
  content: CmsElementText;
16
17
  }>();
17
18
  const context = getCurrentInstance();
18
- const { getConfigValue } = useCmsElementConfig(props.content);
19
+ const { getConfigValue } = useCmsElementConfig(() => props.content);
19
20
 
20
- const mappedContent = computed<string>(() => {
21
- return props.content?.data?.content || getConfigValue("content");
22
- });
21
+ const mappedContent = computed(() => getTextElementContent(props.content));
23
22
 
24
23
  const style = computed<CSSProperties>(() => ({
25
24
  alignContent: getConfigValue("verticalAlign"),
@@ -32,6 +31,10 @@ const CmsTextRender = defineComponent({
32
31
  const { resolveUrl } = useUrlResolver();
33
32
 
34
33
  const config = {
34
+ container: {
35
+ type: "div",
36
+ class: "cms-element-text",
37
+ },
35
38
  textTransformer: (text: string) => decodeHTML(text),
36
39
  extraComponentsMap: {
37
40
  link: {
@@ -145,12 +148,14 @@ const CmsTextRender = defineComponent({
145
148
  },
146
149
  },
147
150
  };
148
- const rawHtml =
149
- mappedContent.value?.length > 0
150
- ? mappedContent.value
151
- : "<div class='cms-element-text missing-content-element'></div>";
152
-
153
- return () => renderHtml(rawHtml, config, h, context, resolveUrl);
151
+ return () =>
152
+ renderHtml(
153
+ mappedContent.value || "<div class='missing-content-element'></div>",
154
+ config,
155
+ h,
156
+ context,
157
+ resolveUrl,
158
+ );
154
159
  },
155
160
  });
156
161
  </script>
@@ -160,34 +165,3 @@ const CmsTextRender = defineComponent({
160
165
  </div>
161
166
  <CmsTextRender v-else />
162
167
  </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,27 @@
1
+ Play a video uploaded to the Shopware media library, in the `video` block. YouTube and Vimeo videos have their own elements.
2
+
3
+ Every option of the element in the Administration is supported:
4
+
5
+ | Option | Behaviour |
6
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | Video | Static or mapped media. Nothing is rendered when it does not resolve. |
8
+ | Display mode | `standard` keeps the video's size, `stretch` makes it full width, `cover` fills the element and crops the video. |
9
+ | Minimum height | Applies to `cover` only. |
10
+ | Vertical / horizontal align | Positions a `standard` or `stretch` video. Ignored for `cover`. |
11
+ | Play automatically | Also mutes the video, because browsers only autoplay muted videos. |
12
+ | Play muted, Play in a loop | Set `muted` and `loop`. |
13
+ | Play inline on iOS devices | Sets `playsinline`. |
14
+ | Show controls | Shows the native controls. Without them the whole element is a play/pause button with a play icon, operable by keyboard too. |
15
+ | Load only after confirmation | Shows the cover image set for the video in the media module and loads nothing until playback starts. Turns autoplay off. Without a cover image, a `stretch` video keeps a 16:9 box until it loads. |
16
+ | Screen reader title | Names the video, falling back to the media alt text (and title for the tooltip). |
17
+
18
+ Without controls, the button's name always says what it does: play or pause. The video's name is announced as its description. A video that cannot be loaded, for example in a format the browser does not play or with a missing file, shows an error in place of the play icon and names the button after the error.
19
+
20
+ The texts can be translated through `cmsTranslations`:
21
+
22
+ | Key | Default |
23
+ | ------------------------ | ---------------------------------------------------- |
24
+ | `cms.video.playLabel` | `Play video` |
25
+ | `cms.video.pauseLabel` | `Pause video` |
26
+ | `cms.video.loadError` | `The video could not be loaded.` |
27
+ | `cms.video.notSupported` | `Your browser does not support the HTML5 video tag.` |
@@ -0,0 +1,193 @@
1
+ <script setup lang="ts">
2
+ import type { CmsElementVideo } from "@shopware/composables";
3
+ import { defu } from "defu";
4
+ import { computed, onMounted, ref, useId, useTemplateRef } from "vue";
5
+
6
+ import { useCmsTranslations } from "#imports";
7
+
8
+ import {
9
+ getVideoAttributes,
10
+ getVideoElementOptions,
11
+ getVideoToggleAttributes,
12
+ } from "../../../../helpers/cms/getVideoElementOptions";
13
+
14
+ const props = defineProps<{
15
+ content: CmsElementVideo;
16
+ }>();
17
+
18
+ type Translations = {
19
+ cms: {
20
+ video: {
21
+ playLabel: string;
22
+ pauseLabel: string;
23
+ loadError: string;
24
+ notSupported: string;
25
+ };
26
+ };
27
+ };
28
+
29
+ let translations: Translations = {
30
+ cms: {
31
+ video: {
32
+ playLabel: "Play video",
33
+ pauseLabel: "Pause video",
34
+ loadError: "The video could not be loaded.",
35
+ notSupported: "Your browser does not support the HTML5 video tag.",
36
+ },
37
+ },
38
+ };
39
+
40
+ translations = defu(useCmsTranslations(), translations) as Translations;
41
+
42
+ const JUSTIFY_CLASSES = {
43
+ "flex-start": "justify-start",
44
+ center: "justify-center",
45
+ "flex-end": "justify-end",
46
+ } as const;
47
+
48
+ const ALIGN_CLASSES = {
49
+ "flex-start": "self-start",
50
+ center: "self-center",
51
+ "flex-end": "self-end",
52
+ } as const;
53
+
54
+ const options = computed(() => getVideoElementOptions(props.content));
55
+ const videoAttributes = computed(() => getVideoAttributes(options.value));
56
+
57
+ const videoId = useId();
58
+ const videoElement = useTemplateRef<HTMLVideoElement>("videoElement");
59
+ const isPlaying = ref(false);
60
+ const hasFailed = ref(false);
61
+
62
+ function syncPlayingState() {
63
+ const video = videoElement.value;
64
+ isPlaying.value = !!video && !video.paused && !video.ended;
65
+ }
66
+
67
+ function markFailed() {
68
+ hasFailed.value = true;
69
+ videoElement.value?.pause();
70
+ isPlaying.value = false;
71
+ }
72
+
73
+ function togglePlayback() {
74
+ const video = videoElement.value;
75
+ if (!video || hasFailed.value) return;
76
+
77
+ if (video.error || video.networkState === video.NETWORK_NO_SOURCE) {
78
+ markFailed();
79
+ return;
80
+ }
81
+
82
+ if (video.paused || video.ended) {
83
+ video.play()?.catch((error: unknown) => {
84
+ if ((error as DOMException | undefined)?.name === "NotSupportedError") {
85
+ markFailed();
86
+ }
87
+ });
88
+ return;
89
+ }
90
+
91
+ video.pause();
92
+ }
93
+
94
+ function onKeydown(event: KeyboardEvent) {
95
+ if (event.key !== "Enter" && event.key !== " ") return;
96
+
97
+ event.preventDefault();
98
+ if (event.repeat) return;
99
+
100
+ togglePlayback();
101
+ }
102
+
103
+ const toggleAttrs = computed(() =>
104
+ getVideoToggleAttributes(
105
+ options.value,
106
+ { isPlaying: isPlaying.value, hasFailed: hasFailed.value, videoId },
107
+ translations.cms.video,
108
+ ),
109
+ );
110
+
111
+ const toggleListeners = computed(() =>
112
+ options.value.controls ? {} : { click: togglePlayback, keydown: onKeydown },
113
+ );
114
+
115
+ onMounted(syncPlayingState);
116
+ </script>
117
+ <template>
118
+ <div
119
+ v-if="options.src"
120
+ class="cms-element-video relative h-full"
121
+ :class="[
122
+ `is-${options.displayMode}`,
123
+ options.displayMode !== 'cover' && 'flex',
124
+ options.horizontalAlign && JUSTIFY_CLASSES[options.horizontalAlign],
125
+ ]"
126
+ v-bind="toggleAttrs"
127
+ v-on="toggleListeners"
128
+ >
129
+ <div
130
+ class="cms-element-alignment min-w-0 max-w-full"
131
+ :class="[
132
+ options.verticalAlign && ALIGN_CLASSES[options.verticalAlign],
133
+ options.displayMode !== 'standard' && 'w-full',
134
+ options.displayMode === 'cover' && 'h-full',
135
+ ]"
136
+ >
137
+ <div
138
+ class="cms-video-container relative w-full"
139
+ :class="{ 'h-full': options.displayMode === 'cover' }"
140
+ :style="
141
+ options.minHeight ? { minHeight: options.minHeight } : undefined
142
+ "
143
+ >
144
+ <span
145
+ v-if="!options.controls && !hasFailed"
146
+ class="cms-video-play-icon pointer-events-none absolute left-1/2 top-1/2 z-2 flex h-16 w-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white transition-opacity duration-200"
147
+ :class="isPlaying ? 'opacity-0' : 'opacity-100'"
148
+ aria-hidden="true"
149
+ >
150
+ <span class="i-carbon-play-filled-alt h-8 w-8" />
151
+ </span>
152
+ <span
153
+ v-if="hasFailed"
154
+ class="cms-video-error pointer-events-none absolute inset-0 z-2 flex items-center justify-center p-4"
155
+ >
156
+ <span
157
+ class="rounded bg-black/60 px-3 py-2 text-center text-sm text-white"
158
+ >
159
+ {{ translations.cms.video.loadError }}
160
+ </span>
161
+ </span>
162
+ <video
163
+ :id="videoId"
164
+ ref="videoElement"
165
+ class="cms-video block max-w-full"
166
+ :class="{
167
+ 'w-full': options.displayMode !== 'standard',
168
+ 'absolute inset-0 h-full object-cover':
169
+ options.displayMode === 'cover',
170
+ 'cursor-pointer': !options.controls && !hasFailed,
171
+ }"
172
+ :style="
173
+ options.placeholderAspectRatio
174
+ ? { aspectRatio: options.placeholderAspectRatio }
175
+ : undefined
176
+ "
177
+ v-bind="videoAttributes"
178
+ @play="syncPlayingState"
179
+ @pause="syncPlayingState"
180
+ @ended="syncPlayingState"
181
+ @error="markFailed"
182
+ >
183
+ <source
184
+ :src="options.src"
185
+ :type="options.mimeType"
186
+ @error="markFailed"
187
+ />
188
+ {{ translations.cms.video.notSupported }}
189
+ </video>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </template>
@@ -6,12 +6,12 @@ import { computed, provide } from "vue";
6
6
  const props = defineProps<{
7
7
  content: CmsSectionSidebar;
8
8
  }>();
9
- const { getPositionContent, section } = useCmsSection(props.content);
9
+ const { getPositionContent } = useCmsSection(() => props.content);
10
10
 
11
- const sidebarBlocks = getPositionContent("sidebar");
12
- const mainBlocks = getPositionContent("main");
11
+ const sidebarBlocks = computed(() => getPositionContent("sidebar"));
12
+ const mainBlocks = computed(() => getPositionContent("main"));
13
13
  const mobileBehavior = computed(() => props.content.mobileBehavior);
14
- const fullWidth = computed(() => section.sizingMode === "full_width");
14
+ const fullWidth = computed(() => props.content.sizingMode === "full_width");
15
15
 
16
16
  // Provide layout context for child components
17
17
  provide("cms-section-layout", "sidebar");