apostrophe 4.10.0 → 4.11.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 (28) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -2
  3. package/modules/@apostrophecms/asset/lib/webpack/apos/webpack.scss.js +0 -9
  4. package/modules/@apostrophecms/color-field/index.js +1 -1
  5. package/modules/@apostrophecms/color-field/ui/apos/components/AposColor.vue +9 -1
  6. package/modules/@apostrophecms/color-field/ui/apos/lib/AposColorEditableInput.vue +11 -3
  7. package/modules/@apostrophecms/color-field/ui/apos/lib/AposColorSaturation.vue +0 -1
  8. package/modules/@apostrophecms/color-field/ui/apos/logic/AposInputColor.js +8 -2
  9. package/modules/@apostrophecms/color-field/ui/apos/mixins/AposColorMixin.js +4 -0
  10. package/modules/@apostrophecms/doc/index.js +7 -3
  11. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +0 -1
  12. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +6 -0
  13. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +6 -1
  14. package/modules/@apostrophecms/modal/ui/apos/composables/AposFocus.js +72 -9
  15. package/modules/@apostrophecms/module/index.js +6 -1
  16. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerSelectBox.vue +2 -0
  17. package/modules/@apostrophecms/rich-text-widget/index.js +3 -1
  18. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapColor.vue +10 -3
  19. package/modules/@apostrophecms/schema/lib/addFieldTypes.js +7 -0
  20. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRange.js +2 -2
  21. package/modules/@apostrophecms/template/lib/bundlesLoader.js +18 -4
  22. package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +12 -1
  23. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +97 -11
  24. package/modules/@apostrophecms/ui/ui/apos/components/AposToggle.vue +5 -1
  25. package/modules/@apostrophecms/ui/ui/apos/composables/AposFocusTrap.js +227 -0
  26. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +0 -1
  27. package/package.json +1 -1
  28. package/test/asset-external.js +183 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.11.0 (2024-12-18)
4
+
5
+ ### Adds
6
+
7
+ * When validating an `area` field, warn the developer if `widgets` is not nested in `options`.
8
+ * Adds support for supplying CSS variable names to a color field's `presetColors` array as selectable values.
9
+ * Adds support for dynamic focus trap in Context menus (prop `dynamicFocus`). When set to `true`, the focusable elements are recalculated on each cycle step.
10
+ * Adds option to disable `tabindex` on `AposToggle` component. A new prop `disableFocus` can be set to `false` to disable the focus on the toggle button. It's enabled by default.
11
+ * Adds support for event on `addContextOperation`, an option `type` can now be passed and can be `modal` (default) or `event`, in this case it does not try to open a modal but emit a bus event using the action as name.
12
+
13
+
14
+ ### Fixes
15
+
16
+ * Focus properly Widget Editor modals when opened. Keep the previous active focus on the modal when closing the widget editor.
17
+ * a11y improvements for context menus.
18
+ * Fixes broken widget preview URL when the image is overridden (module improve) and external build module is registered.
19
+ * Inject dynamic custom bundle CSS when using external build module with no CSS entry point.
20
+ * Range field now correctly takes 0 into account.
21
+ * Apos style does not go through `postcss-viewport-to-container-toggle` plugin anymore to avoid UI bugs.
22
+
3
23
  ## 4.10.0 (2024-11-20)
4
24
 
5
25
  ### Fixes
@@ -429,11 +429,11 @@ export default {
429
429
  },
430
430
 
431
431
  attachKeyboardFocusHandler() {
432
- this.$refs.wrapper.addEventListener('keydown', this.handleKeyboardFocus);
432
+ this.$refs.wrapper?.addEventListener('keydown', this.handleKeyboardFocus);
433
433
  },
434
434
 
435
435
  removeKeyboardFocusHandler() {
436
- this.$refs.wrapper.removeEventListener('keydown', this.handleKeyboardFocus);
436
+ this.$refs.wrapper?.removeEventListener('keydown', this.handleKeyboardFocus);
437
437
  },
438
438
 
439
439
  // Focus parent, useful for obtrusive UI
@@ -1,14 +1,5 @@
1
- const postcssViewportToContainerToggle = require('postcss-viewport-to-container-toggle');
2
-
3
1
  module.exports = (options, apos) => {
4
2
  const postcssPlugins = [
5
- ...apos.asset.options.breakpointPreviewMode?.enable === true ? [
6
- postcssViewportToContainerToggle({
7
- modifierAttr: 'data-breakpoint-preview-mode',
8
- debug: apos.asset.options.breakpointPreviewMode?.debug === true,
9
- transform: apos.asset.options.breakpointPreviewMode?.transform || null
10
- })
11
- ] : [],
12
3
  'autoprefixer',
13
4
  {}
14
5
  ];
@@ -24,7 +24,7 @@ module.exports = {
24
24
  }
25
25
 
26
26
  const test = new TinyColor(destination[field.name]);
27
- if (!test.isValid) {
27
+ if (!test.isValid && !destination[field.name].startsWith('--')) {
28
28
  destination[field.name] = null;
29
29
  }
30
30
  },
@@ -19,6 +19,7 @@
19
19
  </div>
20
20
  <div class="apos-color__color-wrap">
21
21
  <div
22
+ :data-apos-active-color="activeColor"
22
23
  :aria-label="`Current color is ${activeColor}`"
23
24
  class="apos-color__active-color"
24
25
  :style="{ background: activeColor }"
@@ -77,7 +78,7 @@
77
78
  :key="`!${c}`"
78
79
  class="apos-color__presets-color"
79
80
  :aria-label="`Color:${c}`"
80
- :style="{ background: c }"
81
+ :style="{ background: formatCssValue(c) }"
81
82
  @click="handlePreset(c)"
82
83
  />
83
84
  <div
@@ -152,6 +153,13 @@ export default {
152
153
  }
153
154
  },
154
155
  methods: {
156
+ formatCssValue(c) {
157
+ let value = c;
158
+ if (c.startsWith('--')) {
159
+ value = `var(${value})`;
160
+ }
161
+ return value;
162
+ },
155
163
  handlePreset(c) {
156
164
  this.colorChange(c);
157
165
  },
@@ -124,13 +124,21 @@ export default {
124
124
  }
125
125
 
126
126
  .apos-color__input {
127
- width: 100%;
128
- padding: 0;
129
- border: 0;
127
+ width: 90%;
128
+ padding: 4px 0 3px 10%;
129
+ border: none;
130
+ font-size: var(--a-type-small);
130
131
  outline: none;
132
+ box-shadow: inset 0 0 0 1px var(--a-base-5);
131
133
  }
132
134
 
133
135
  .apos-color__label {
136
+ display: block;
137
+ padding-top: 3px;
138
+ padding-bottom: 4px;
139
+ color: var(--a-text-primary);
140
+ font-size: var(--a-type-smaller);
141
+ text-align: center;
134
142
  text-transform: capitalize;
135
143
  }
136
144
  </style>
@@ -73,7 +73,6 @@ export default {
73
73
  this.$emit('change', param);
74
74
  },
75
75
  handleMouseDown(e) {
76
- // this.handleChange(e, true)
77
76
  window.addEventListener('mousemove', this.handleChange);
78
77
  window.addEventListener('mouseup', this.handleChange);
79
78
  window.addEventListener('mouseup', this.handleMouseUp);
@@ -76,7 +76,11 @@ export default {
76
76
  },
77
77
  update(value) {
78
78
  this.tinyColorObj = new TinyColor(value.hsl);
79
- this.next = this.tinyColorObj.toString(this.format);
79
+ if (value._cssVariable) {
80
+ this.next = value._cssVariable;
81
+ } else {
82
+ this.next = this.tinyColorObj.toString(this.format);
83
+ }
80
84
  },
81
85
  validate(value) {
82
86
  if (this.field.required) {
@@ -90,7 +94,9 @@ export default {
90
94
  }
91
95
 
92
96
  const color = new TinyColor(value);
93
- return color.isValid ? false : 'Error';
97
+ if (!value.startsWith('--')) {
98
+ return color.isValid ? false : 'Error';
99
+ }
94
100
  },
95
101
  clear() {
96
102
  this.next = '';
@@ -19,6 +19,9 @@ function _colorChange(data, oldHue) {
19
19
  color = tinycolor(data.rgba);
20
20
  } else if (data && data.rgb) {
21
21
  color = tinycolor(data.rgb);
22
+ } else if (data && typeof data === 'string' && data.startsWith('--')) {
23
+ color = tinycolor(getComputedStyle(document.body).getPropertyValue(data));
24
+ color._cssVariable = data;
22
25
  } else {
23
26
  color = tinycolor(data);
24
27
  }
@@ -50,6 +53,7 @@ function _colorChange(data, oldHue) {
50
53
  /* ------ */
51
54
 
52
55
  return {
56
+ _cssVariable: color._cssVariable,
53
57
  hsl,
54
58
  hex: color.toHexString().toUpperCase(),
55
59
  hex8: color.toHex8String().toUpperCase(),
@@ -1486,7 +1486,7 @@ module.exports = {
1486
1486
  ];
1487
1487
 
1488
1488
  function validate ({
1489
- action, context, label, modal, conditions, if: ifProps
1489
+ action, context, type = 'modal', label, modal, conditions, if: ifProps
1490
1490
  }) {
1491
1491
  const allowedConditions = [
1492
1492
  'canPublish',
@@ -1503,8 +1503,12 @@ module.exports = {
1503
1503
  'canShareDraft'
1504
1504
  ];
1505
1505
 
1506
- if (!action || !context || !label || !modal) {
1507
- throw self.apos.error('invalid', 'addContextOperation requires action, context, label and modal properties.');
1506
+ if (![ 'event', 'modal' ].includes(type)) {
1507
+ throw self.apos.error('invalid', '`type` option must be `modal` (default) or `event`');
1508
+ }
1509
+
1510
+ if (!action || !context || !label || (type === 'modal' && !modal)) {
1511
+ throw self.apos.error('invalid', 'addContextOperation requires action, context, label and modal (if type is set to `modal` or unset) properties.');
1508
1512
  }
1509
1513
 
1510
1514
  if (
@@ -24,7 +24,6 @@ import AposDocContextMenuLogic from 'Modules/@apostrophecms/doc-type/logic/AposD
24
24
  export default {
25
25
  name: 'AposDocContextMenu',
26
26
  mixins: [ AposDocContextMenuLogic ],
27
- // Satisfy linting.
28
27
  emits: [ 'menu-open', 'menu-close', 'close' ]
29
28
  };
30
29
  </script>
@@ -168,6 +168,7 @@ export default {
168
168
  }
169
169
  ] : [])
170
170
  ];
171
+
171
172
  return menu;
172
173
  },
173
174
  customMenusByContext() {
@@ -394,6 +395,7 @@ export default {
394
395
  this.customAction(this.context, operation);
395
396
  return;
396
397
  }
398
+
397
399
  this[action](this.context);
398
400
  },
399
401
  async edit(doc) {
@@ -449,6 +451,10 @@ export default {
449
451
  ...docProps(doc),
450
452
  ...operation.props
451
453
  };
454
+ if (operation.type === 'event') {
455
+ apos.bus.$emit(operation.action, props);
456
+ return;
457
+ }
452
458
  await apos.modal.execute(operation.modal, props);
453
459
  function docProps(doc) {
454
460
  return Object.fromEntries(Object.entries(operation.docProps || {}).map(([ key, value ]) => {
@@ -13,7 +13,7 @@
13
13
  aria-modal="true"
14
14
  :aria-labelledby="props.modalData.id"
15
15
  data-apos-modal
16
- @focus.capture="storeFocusedElement"
16
+ @focus.capture="captureFocus"
17
17
  @esc="close"
18
18
  @keydown.tab="onTab"
19
19
  >
@@ -264,6 +264,11 @@ function onLeave() {
264
264
  emit('no-modal');
265
265
  }
266
266
 
267
+ function captureFocus(e) {
268
+ storeFocusedElement(e);
269
+ store.updateModalData(props.modalData.id, { focusedElement: e.target });
270
+ }
271
+
267
272
  async function trapFocus() {
268
273
  if (modalEl?.value) {
269
274
  const elementSelectors = [
@@ -10,6 +10,9 @@ export function useAposFocus() {
10
10
  return {
11
11
  elementsToFocus,
12
12
  focusedElement,
13
+ activeModal: modalStore.activeModal,
14
+ activeModalElementsToFocus: modalStore.activeModal?.elementsToFocus,
15
+ activeModalFocusedElement: modalStore.activeModal?.focusedElement,
13
16
  cycleElementsToFocus,
14
17
  focusLastModalFocusedElement,
15
18
  storeFocusedElement,
@@ -26,7 +29,18 @@ export function useAposFocus() {
26
29
  // `cycleElementsToFocus` listeners relies on this dynamic list which has the advantage of
27
30
  // taking new or less elements to focus, after an update has happened inside a modal,
28
31
  // like an XHR call to get the pieces list in the AposDocsManager modal, for instance.
29
- function cycleElementsToFocus(e, elements) {
32
+ // If the fnFocus argument is provided, it will be called with the event and
33
+ // the element to focus. Otherwise, the default behavior is to focus the element
34
+ // and prevent the default event behavior.
35
+ /**
36
+ * @param {KeyboardEvent} e event
37
+ * @param {HTMLElement[]} elements
38
+ * @param {
39
+ * (event: KeyboardEvent, element: HTMLElement) => void
40
+ * } [fnFocus] optional function to focus the element
41
+ * @returns {void}
42
+ */
43
+ function cycleElementsToFocus(e, elements, fnFocus) {
30
44
  const elems = elements || elementsToFocus.value;
31
45
  if (!elems.length) {
32
46
  return;
@@ -37,25 +51,74 @@ export function useAposFocus() {
37
51
  return;
38
52
  }
39
53
 
40
- const firstElementToFocus = elems.at(0);
41
- const lastElementToFocus = elems.at(-1);
54
+ let firstElementToFocus = elems.at(0);
55
+ let lastElementToFocus = elems.at(-1);
56
+
57
+ // Take into account radio inputs with the same name, the
58
+ // browser will cycle through them as a group, stepping on
59
+ // the active one per stack.
60
+ const firstElementRadioStack = getInputRadioStack(firstElementToFocus, elems);
61
+ const lastElementRadioStack = getInputRadioStack(lastElementToFocus, elems);
62
+ firstElementToFocus = getInputCheckedOrCurrent(firstElementToFocus, firstElementRadioStack);
63
+ lastElementToFocus = getInputCheckedOrCurrent(lastElementToFocus, lastElementRadioStack);
64
+
65
+ const focus = fnFocus || ((ev, el) => {
66
+ el.focus();
67
+ ev.preventDefault();
68
+ });
42
69
 
43
70
  // If shift key pressed for shift + tab combination
44
71
  if (e.shiftKey) {
45
- if (document.activeElement === firstElementToFocus) {
72
+ if (document.activeElement === firstElementToFocus ||
73
+ firstElementRadioStack.includes(document.activeElement)
74
+ ) {
46
75
  // Add focus for the last focusable element
47
- lastElementToFocus.focus();
48
- e.preventDefault();
76
+ focus(e, lastElementToFocus);
49
77
  }
50
78
  return;
51
79
  }
52
80
 
53
81
  // If tab key is pressed
54
- if (document.activeElement === lastElementToFocus) {
82
+ if (document.activeElement === lastElementToFocus ||
83
+ lastElementRadioStack.includes(document.activeElement)
84
+ ) {
55
85
  // Add focus for the first focusable element
56
- firstElementToFocus.focus();
57
- e.preventDefault();
86
+ focus(e, firstElementToFocus);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Returns an array of radio inputs with the same name attribute
92
+ * as the current element. If the current element is not a radio input,
93
+ * an empty array is returned.
94
+ *
95
+ * @param {HTMLElement} currentElement
96
+ * @param {HTMLElement[]} elements
97
+ * @returns {HTMLElement[]}
98
+ */
99
+ function getInputRadioStack(currentElement, elements) {
100
+ return currentElement.getAttribute('type') === 'radio'
101
+ ? elements.filter(
102
+ e => (e.getAttribute('type') === 'radio' &&
103
+ e.getAttribute('name') === currentElement.getAttribute('name'))
104
+ )
105
+ : [];
106
+ }
107
+
108
+ /**
109
+ *
110
+ * @param {HTMLElement} currentElement
111
+ * @param {HTMLElement[]} elements
112
+ * @returns
113
+ */
114
+ function getInputCheckedOrCurrent(currentElement, elements = []) {
115
+ const checked = elements.find(el => (el.hasAttribute('checked')));
116
+
117
+ if (checked) {
118
+ return checked;
58
119
  }
120
+
121
+ return currentElement;
59
122
  }
60
123
 
61
124
  // Focus the last focused element from the last modal.
@@ -818,7 +818,8 @@ module.exports = {
818
818
  let urlOption = self.options[`${name}Url`];
819
819
  const imageOption = self.options[`${name}Image`];
820
820
  if (!urlOption) {
821
- if (imageOption) {
821
+ // Webpack and the legacy asset pipeline
822
+ if (imageOption && !self.apos.asset.hasBuildModule()) {
822
823
  const chain = [ ...self.__meta.chain ].reverse();
823
824
  for (const entry of chain) {
824
825
  const path = `${entry.dirname}/public/${name}.${imageOption}`;
@@ -828,6 +829,10 @@ module.exports = {
828
829
  }
829
830
  }
830
831
  }
832
+ // The new external module asset pipeline
833
+ if (imageOption && self.apos.asset.hasBuildModule()) {
834
+ urlOption = `/modules/${self.__meta.name}/${name}.${imageOption}`;
835
+ }
831
836
  }
832
837
  if (urlOption && urlOption.startsWith('/modules')) {
833
838
  urlOption = self.apos.asset.url(urlOption);
@@ -17,6 +17,7 @@
17
17
  :label="selectBoxMessageButton"
18
18
  class="apos-select-box__select-all"
19
19
  text-color="var(--a-primary)"
20
+ :disabled="!showSelectAll"
20
21
  @click="$emit('select-all')"
21
22
  />
22
23
  <AposButton
@@ -26,6 +27,7 @@
26
27
  label="apostrophe:clearSelection"
27
28
  class="apos-select-box__select-all"
28
29
  text-color="var(--a-primary)"
30
+ :disabled="!showSelectAll"
29
31
  @click="clearSelection"
30
32
  />
31
33
  </p>
@@ -617,7 +617,9 @@ module.exports = {
617
617
  // HSL colors
618
618
  /^hsl\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*\)$/,
619
619
  // HSLA colors
620
- /^hsla\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*,\s*(?:0?\.\d+|1(?:\.0)?)\s*\)$/
620
+ /^hsla\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*,\s*(?:0?\.\d+|1(?:\.0)?)\s*\)$/,
621
+ // CSS Variable value
622
+ /^var\(--[a-zA-Z0-9-]+\)$/
621
623
  ]
622
624
  }
623
625
  }
@@ -177,9 +177,16 @@ export default defineComponent({
177
177
  };
178
178
 
179
179
  const update = (value) => {
180
- tinyColorObj.value = new TinyColor(value.hsl);
181
- next.value = tinyColorObj.value.toString(format.value);
182
- props.editor.chain().focus().setColor(next.value).run();
180
+ let color;
181
+ if (value._cssVariable) {
182
+ next.value = value._cssVariable;
183
+ color = `var(${next.value})`;
184
+ } else {
185
+ tinyColorObj.value = new TinyColor(value.hsl);
186
+ next.value = tinyColorObj.value.toString(format.value);
187
+ color = next.value;
188
+ }
189
+ props.editor.chain().focus().setColor(color).run();
183
190
  indicatorColor.value = next.value;
184
191
  };
185
192
 
@@ -56,6 +56,13 @@ module.exports = (self) => {
56
56
  return _.isEqual(oneArea, twoArea);
57
57
  },
58
58
  validate: function (field, options, warn, fail) {
59
+ if (field.widgets) {
60
+ warn(stripIndents`
61
+ Remember to nest "widgets" inside "options" when configuring an area field.
62
+
63
+ Otherwise, "widgets" has no effect.
64
+ `);
65
+ }
59
66
  let widgets = (field.options && field.options.widgets) || {};
60
67
 
61
68
  if (field.options && field.options.groups) {
@@ -32,7 +32,7 @@ export default {
32
32
  // The range spec defaults to a value of midway between the min and max
33
33
  // Example: a range with an unset value and a min of 0 and max of 100 will become 50
34
34
  // This does not allow ranges to go unset :(
35
- if (!this.next) {
35
+ if (!this.next && this.next !== 0) {
36
36
  this.unset();
37
37
  }
38
38
  },
@@ -47,7 +47,7 @@ export default {
47
47
  },
48
48
  validate(value) {
49
49
  if (this.field.required) {
50
- if (!value) {
50
+ if (!value && value !== 0) {
51
51
  return 'required';
52
52
  }
53
53
  }
@@ -1,6 +1,12 @@
1
1
  const { stripIndent } = require('common-tags');
2
2
 
3
3
  module.exports = (self) => {
4
+ // FIXME: This entire function should be separated for external build modules.
5
+ // Use the next opportunity to clean up and let the legacy system be and
6
+ // introduce a new one for external build modules (e.g. `insertBundlesMarkupByManifest`).
7
+ // The only check for external build modules should be at the very top of
8
+ // `insertBundlesMarkup` function, resulting in a call to our new
9
+ // function.
4
10
  function insertBundlesMarkup({
5
11
  page = {},
6
12
  template = '',
@@ -30,7 +36,7 @@ module.exports = (self) => {
30
36
  });
31
37
 
32
38
  if (scene === 'apos') {
33
- return loadAllBundles({
39
+ return loadAllBundles(self, {
34
40
  content,
35
41
  scriptsPlaceholder,
36
42
  stylesheetsPlaceholder,
@@ -75,6 +81,10 @@ module.exports = (self) => {
75
81
  };
76
82
  }, widgetsBundles);
77
83
 
84
+ const cssExtraBundles = self.apos.asset.hasBuildModule()
85
+ ? Array.from(new Set([ ...extraBundles.js, ...extraBundles.css ]))
86
+ : extraBundles.css;
87
+
78
88
  const { jsBundles, cssBundles } = Object.entries(configs)
79
89
  .reduce((acc, [ name, { templates } ]) => {
80
90
  if (templates && !templates.includes(templateType)) {
@@ -90,7 +100,7 @@ module.exports = (self) => {
90
100
  });
91
101
 
92
102
  const cssMarkup = stylesheetsPlaceholder &&
93
- extraBundles.css.includes(name) &&
103
+ cssExtraBundles.includes(name) &&
94
104
  renderMarkup({
95
105
  fileName: name,
96
106
  ext: 'css'
@@ -175,7 +185,7 @@ function renderBundleMarkupByManifest(self, modulePreload) {
175
185
  };
176
186
  }
177
187
 
178
- function loadAllBundles({
188
+ function loadAllBundles(self, {
179
189
  content,
180
190
  extraBundles,
181
191
  scriptsPlaceholder,
@@ -199,11 +209,15 @@ function loadAllBundles({
199
209
  `;
200
210
  };
201
211
 
212
+ const cssExtraBundles = self.apos.asset.hasBuildModule()
213
+ ? Array.from(new Set([ ...extraBundles.js, ...extraBundles.css ]))
214
+ : extraBundles.css;
215
+
202
216
  const jsBundles = extraBundles.js.reduce(
203
217
  (acc, bundle) => reduceToMarkup(acc, bundle, 'js'), jsMainBundle
204
218
  );
205
219
 
206
- const cssBundles = extraBundles.css.reduce(
220
+ const cssBundles = cssExtraBundles.reduce(
207
221
  (acc, bundle) => reduceToMarkup(acc, bundle, 'css'), cssMainBundle
208
222
  );
209
223
 
@@ -9,6 +9,7 @@
9
9
  v-bind="attrs"
10
10
  :is="href ? 'a' : 'button'"
11
11
  :id="attrs.id ? attrs.id : id"
12
+ ref="buttonTrigger"
12
13
  :target="target"
13
14
  :href="href"
14
15
  class="apos-button"
@@ -164,8 +165,15 @@ export default {
164
165
  if (this.type === 'color') {
165
166
  // if color exists, use it
166
167
  if (this.color) {
168
+
169
+ let color = this.color;
170
+
171
+ if (color.startsWith('--')) {
172
+ color = `var(${color})`;
173
+ }
174
+
167
175
  return {
168
- backgroundColor: this.color
176
+ backgroundColor: color
169
177
  };
170
178
  // if not provide a default placeholder
171
179
  } else {
@@ -235,6 +243,9 @@ export default {
235
243
  methods: {
236
244
  click($event) {
237
245
  this.$emit('click', $event);
246
+ },
247
+ focus() {
248
+ (this.$refs.buttonTrigger?.$el ?? this.$refs.buttonTrigger)?.focus();
238
249
  }
239
250
  }
240
251
  };
@@ -1,5 +1,9 @@
1
1
  <template>
2
- <div class="apos-context-menu">
2
+ <section
3
+ ref="contextMenuRef"
4
+ class="apos-context-menu"
5
+ @keydown.tab="onTab"
6
+ >
3
7
  <slot name="prebutton" />
4
8
  <div
5
9
  ref="dropdown"
@@ -7,7 +11,7 @@
7
11
  >
8
12
  <AposButton
9
13
  v-bind="button"
10
- ref="button"
14
+ ref="dropdownButton"
11
15
  class="apos-context-menu__btn"
12
16
  role="button"
13
17
  :data-apos-test="identifier"
@@ -25,6 +29,7 @@
25
29
  v-if="isOpen"
26
30
  ref="dropdownContent"
27
31
  v-click-outside-element="hide"
32
+ :data-apos-test="isRendered ? 'context-menu-content' : null"
28
33
  class="apos-context-menu__dropdown-content"
29
34
  :class="popoverClass"
30
35
  data-apos-menu
@@ -44,7 +49,7 @@
44
49
  </AposContextMenuDialog>
45
50
  </div>
46
51
  </div>
47
- </div>
52
+ </section>
48
53
  </template>
49
54
 
50
55
  <script setup>
@@ -54,9 +59,11 @@ import {
54
59
  import {
55
60
  computePosition, offset, shift, flip, arrow
56
61
  } from '@floating-ui/dom';
57
- import { useAposTheme } from 'Modules/@apostrophecms/ui/composables/AposTheme';
58
62
  import { createId } from '@paralleldrive/cuid2';
59
63
 
64
+ import { useAposTheme } from '../composables/AposTheme.js';
65
+ import { useFocusTrap } from '../composables/AposFocusTrap.js';
66
+
60
67
  const props = defineProps({
61
68
  identifier: {
62
69
  type: String,
@@ -122,15 +129,34 @@ const props = defineProps({
122
129
  activeItem: {
123
130
  type: String,
124
131
  default: null
132
+ },
133
+ trapFocus: {
134
+ type: Boolean,
135
+ default: true
136
+ },
137
+ // When set to true, the elements to focus on will be re-queried
138
+ // on everu Tab key press. Use this with caution, as it's a performance
139
+ // hit. Only use this if you have a context menu with
140
+ // dynamically changing (e.g. AposToggle item enables another item) items.
141
+ dynamicFocus: {
142
+ type: Boolean,
143
+ default: false
125
144
  }
126
145
  });
127
146
 
128
147
  const emit = defineEmits([ 'open', 'close', 'item-clicked' ]);
129
148
 
130
149
  const isOpen = ref(false);
150
+ const isRendered = ref(false);
131
151
  const placement = ref(props.menuPlacement);
132
152
  const event = ref(null);
153
+ /** @type {import('vue').Ref<HTMLElement | null>}} */
154
+ const contextMenuRef = ref(null);
155
+ /** @type {import('vue').Ref<HTMLElement | null>}} */
133
156
  const dropdown = ref(null);
157
+ /** @type {import('vue').Ref<import('vue').ComponentPublicInstance | null>} */
158
+ const dropdownButton = ref(null);
159
+ /** @type {import('vue').Ref<HTMLElement | null>} */
134
160
  const dropdownContent = ref(null);
135
161
  const dropdownContentStyle = ref({});
136
162
  const arrowEl = ref(null);
@@ -138,6 +164,17 @@ const iconToCenterTo = ref(null);
138
164
  const menuOffset = getMenuOffset();
139
165
  const otherMenuOpened = ref(false);
140
166
 
167
+ const {
168
+ onTab, runTrap, hasRunningTrap, resetTrap
169
+ } = useFocusTrap({
170
+ withPriority: true,
171
+ refreshOnCycle: props.dynamicFocus
172
+ // If enabled, the dropdown gets closed when the focus leaves
173
+ // the context menu.
174
+ // triggerRef: dropdownButton,
175
+ // onExit: hide
176
+ });
177
+
141
178
  defineExpose({
142
179
  hide,
143
180
  setDropdownPosition
@@ -170,19 +207,28 @@ const buttonState = computed(() => {
170
207
  return isOpen.value ? [ 'active' ] : null;
171
208
  });
172
209
 
173
- watch(isOpen, (newVal) => {
210
+ watch(isOpen, async (newVal) => {
174
211
  emit(newVal ? 'open' : 'close', event.value);
175
212
  if (newVal) {
176
213
  setDropdownPosition();
177
214
  window.addEventListener('resize', setDropdownPosition);
178
215
  window.addEventListener('scroll', setDropdownPosition);
179
- window.addEventListener('keydown', handleKeyboard);
180
- dropdownContent.value.querySelector('[tabindex]')?.focus();
216
+ contextMenuRef.value?.addEventListener('keydown', handleKeyboard);
217
+ if (props.trapFocus && !hasRunningTrap.value) {
218
+ await runTrap(dropdownContent);
219
+ }
220
+ if (!props.trapFocus) {
221
+ dropdownContent.value.querySelector('[tabindex]')?.focus();
222
+ }
223
+ isRendered.value = true;
181
224
  } else {
225
+ if (props.trapFocus) {
226
+ resetTrap();
227
+ }
182
228
  window.removeEventListener('resize', setDropdownPosition);
183
229
  window.removeEventListener('scroll', setDropdownPosition);
184
- window.removeEventListener('keydown', handleKeyboard);
185
- if (!otherMenuOpened.value) {
230
+ contextMenuRef.value?.addEventListener('keydown', handleKeyboard);
231
+ if (!otherMenuOpened.value && !props.trapFocus) {
186
232
  dropdown.value.querySelector('[tabindex]').focus();
187
233
  }
188
234
  }
@@ -276,11 +322,51 @@ async function setDropdownPosition() {
276
322
  });
277
323
  }
278
324
 
325
+ const ignoreInputTypes = [
326
+ 'text',
327
+ 'password',
328
+ 'email',
329
+ 'file',
330
+ 'number',
331
+ 'search',
332
+ 'tel',
333
+ 'url',
334
+ 'date',
335
+ 'time',
336
+ 'datetime-local',
337
+ 'month',
338
+ 'search',
339
+ 'week'
340
+ ];
341
+
342
+ /**
343
+ * @param {KeyboardEvent} event
344
+ */
279
345
  function handleKeyboard(event) {
280
- if (event.key === 'Escape') {
346
+ if (event.key !== 'Escape' || !isOpen.value) {
347
+ return;
348
+ }
349
+ /** @type {HTMLElement} */
350
+ const target = event.target;
351
+
352
+ // If inside of an input or textarea, don't close the dropdown
353
+ // and don't allow other event listeners to close it either (e.g. modals)
354
+ if (
355
+ target?.nodeName?.toLowerCase() === 'textarea' ||
356
+ (target?.nodeName?.toLowerCase() === 'input' &&
357
+ ignoreInputTypes.includes(target.getAttribute('type'))
358
+ )
359
+ ) {
281
360
  event.stopImmediatePropagation();
282
- hide();
361
+ return;
283
362
  }
363
+
364
+ dropdownButton.value?.focus
365
+ ? dropdownButton.value.focus()
366
+ : dropdownButton.value?.$el?.focus();
367
+
368
+ event.stopImmediatePropagation();
369
+ hide();
284
370
  }
285
371
  </script>
286
372
 
@@ -2,7 +2,7 @@
2
2
  <div class="apos-toggle__container">
3
3
  <div
4
4
  class="apos-toggle__slider"
5
- tabindex="0"
5
+ :tabindex="disableFocus ? null : '0'"
6
6
  :class="{'apos-toggle__slider--activated': !modelValue}"
7
7
  @click="$emit('toggle')"
8
8
  @keydown.stop.space="$emit('toggle')"
@@ -18,6 +18,10 @@ export default {
18
18
  modelValue: {
19
19
  type: Boolean,
20
20
  required: true
21
+ },
22
+ disableFocus: {
23
+ type: Boolean,
24
+ default: false
21
25
  }
22
26
  },
23
27
  emits: [ 'toggle' ],
@@ -0,0 +1,227 @@
1
+ import { useAposFocus } from 'Modules/@apostrophecms/modal/composables/AposFocus';
2
+ import {
3
+ computed, ref, unref, nextTick
4
+ } from 'vue';
5
+
6
+ /**
7
+ * Handle focus trapping inside a modal or any other element.
8
+ *
9
+ * Options:
10
+ * - `retries`: Number of retries to focus (trap) the first element in the given
11
+ * container. Default is 3.
12
+ * - `refreshOnCycle`: If true, the elements to focus will be refreshed (query)
13
+ * on each cycle. Default is false.
14
+ * - `withPriority`: If true, 'data-apos-focus-priority' attribute will be used
15
+ * to find the first element to focus. Default is true.
16
+ * - `triggerRef`: (optional) A ref to the element that will trigger the focus trap.
17
+ * It's used as a focus target when exiting the current element focusable elements.
18
+ * If boolean `true` is passed, the active modal focused element will be used.
19
+ * - `onExit`: (optional) A callback to be called when exiting the focus trap.
20
+ *
21
+ * @param {{
22
+ * retries?: number;
23
+ * refreshOnCycle?: boolean;
24
+ * withPriority?: boolean;
25
+ * triggerRef?: import('vue').Ref<HTMLElement | import('vue').ComponentPublicInstance>
26
+ * | HTMLElement | boolean;
27
+ * onExit?: () => void;
28
+ * }} options
29
+ * @returns {{
30
+ * runTrap: (containerRef: import('vue').Ref<HTMLElement> | HTMLElement) => Promise<void>;
31
+ * hasRunningTrap: import('vue').ComputedRef<boolean>;
32
+ * stopTrap: () => void;
33
+ * resetTrap: () => void;
34
+ * onTab: (event: KeyboardEvent) => void;
35
+ * }}
36
+ */
37
+ export function useFocusTrap({
38
+ triggerRef,
39
+ refreshOnCycle = false,
40
+ onExit = () => {},
41
+ retries = 3,
42
+ withPriority = true
43
+ }) {
44
+ const {
45
+ activeModalFocusedElement,
46
+ findPriorityElementOrFirst,
47
+ cycleElementsToFocus: parentCycleElementsToFocus,
48
+ focusElement
49
+ } = useAposFocus();
50
+
51
+ const shouldRun = ref(false);
52
+ const isRunning = ref(false);
53
+ const currentRetries = ref(0);
54
+ const rootRef = ref(null);
55
+ const elementsToFocus = ref([]);
56
+ const hasRunningTrap = computed(() => {
57
+ return isRunning.value;
58
+ });
59
+ const triggerRefElement = computed(() => {
60
+ const value = unref(triggerRef);
61
+ if (value === true) {
62
+ return activeModalFocusedElement;
63
+ }
64
+ if (value) {
65
+ const element = value.$el || value;
66
+ if (element instanceof HTMLElement) {
67
+ return element.hasAttribute('tabindex')
68
+ ? element
69
+ : (element.querySelector('[tabindex]') || element);
70
+ }
71
+ }
72
+ return null;
73
+ });
74
+
75
+ const selectors = [
76
+ '[tabindex]',
77
+ '[href]',
78
+ 'input',
79
+ 'select',
80
+ 'textarea',
81
+ 'button',
82
+ '[data-apos-focus-priority]'
83
+ ];
84
+ const selector = selectors
85
+ .map(addExcludingAttributes)
86
+ .join(', ');
87
+
88
+ return {
89
+ runTrap: run,
90
+ hasRunningTrap,
91
+ stopTrap: stop,
92
+ resetTrap: reset,
93
+ onTab: cycle
94
+ };
95
+
96
+ /**
97
+ * The internal implementation of the focus trap.
98
+ */
99
+ async function trapFocus(containerRef) {
100
+ if (!unref(containerRef) || !shouldRun.value) {
101
+ return;
102
+ }
103
+ const elements = [ ...unref(containerRef).querySelectorAll(selector) ];
104
+ const firstElementToFocus = unref(withPriority)
105
+ ? findPriorityElementOrFirst(elements)
106
+ : elements[0];
107
+ const isPriorityElement = unref(withPriority)
108
+ ? firstElementToFocus?.hasAttribute('data-apos-focus-priority')
109
+ : firstElementToFocus;
110
+
111
+ if (!isPriorityElement && unref(retries) > currentRetries.value) {
112
+ currentRetries.value++;
113
+ await wait(20);
114
+ return trapFocus(containerRef);
115
+ }
116
+ await nextTick();
117
+
118
+ if (shouldRun.value) {
119
+ focusElement(findChecked(firstElementToFocus, elements));
120
+ elementsToFocus.value = elements;
121
+ rootRef.value = unref(containerRef);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Run the focus trap
127
+ */
128
+ async function run(containerRef) {
129
+ if (isRunning.value) {
130
+ return;
131
+ }
132
+ shouldRun.value = true;
133
+ isRunning.value = true;
134
+ await trapFocus(containerRef);
135
+ isRunning.value = false;
136
+ shouldRun.value = false;
137
+ }
138
+
139
+ /**
140
+ * Stop the focus trap
141
+ */
142
+ function stop() {
143
+ shouldRun.value = false;
144
+ }
145
+
146
+ /**
147
+ * Reset the focus trap
148
+ */
149
+ function reset() {
150
+ shouldRun.value = false;
151
+ isRunning.value = false;
152
+ currentRetries.value = 0;
153
+ elementsToFocus.value = [];
154
+ }
155
+
156
+ /**
157
+ * Cycle through the elements to focus in the container element.
158
+ * If no modal is active, it will use the natural focusable elements.
159
+ *
160
+ * @param {KeyboardEvent} event
161
+ */
162
+ function cycle(event) {
163
+ if (refreshOnCycle && rootRef.value) {
164
+ const elements = [ ...unref(rootRef).querySelectorAll(selector) ];
165
+ elementsToFocus.value = elements;
166
+ }
167
+ const elements = unref(elementsToFocus);
168
+ parentCycleElementsToFocus(event, elements, focus);
169
+
170
+ // Keep the if branches for better readability and future changes.
171
+ function focus(ev, element) {
172
+ let toFocusEl;
173
+ const currentFocused = triggerRefElement.value;
174
+
175
+ // If no trigger element is found, fallback to the original behavior.
176
+ if (!currentFocused) {
177
+ element.focus();
178
+ ev.preventDefault();
179
+ }
180
+
181
+ // We did a full cycle and are returning back to the first element.
182
+ // We don't want that, but to exit the cycle and continue to the next
183
+ // modal element to focus or the next natural focusable element (if
184
+ // not inside a modal).
185
+ if (element === elements[0]) {
186
+ toFocusEl = currentFocused;
187
+ }
188
+
189
+ // We are shift + tabbing from the first element. We want to focus the
190
+ // modal last focused element if available.
191
+ if (element === elements.at(-1)) {
192
+ toFocusEl = currentFocused;
193
+ }
194
+
195
+ if (toFocusEl) {
196
+ toFocusEl.focus();
197
+ ev.preventDefault();
198
+ }
199
+
200
+ // The focus handler is called ONLY when we are exiting the container
201
+ // element. No matter if we find a focusable element or not, we should
202
+ // call the onExit callback - the focus should be outside the container
203
+ // element.
204
+ onExit();
205
+ }
206
+ }
207
+ };
208
+
209
+ function addExcludingAttributes(selector) {
210
+ return `${selector}:not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden])`;
211
+ }
212
+
213
+ function findChecked(element, elements) {
214
+ if (element?.getAttribute('type') === 'radio') {
215
+ return elements.find(
216
+ el => (el.getAttribute('type') === 'radio' &&
217
+ el.getAttribute('name') === element.getAttribute('name') &&
218
+ el.hasAttribute('checked'))
219
+ ) || element;
220
+ }
221
+
222
+ return element;
223
+ }
224
+
225
+ async function wait(ms) {
226
+ return new Promise((resolve) => setTimeout(resolve, ms));
227
+ }
@@ -51,7 +51,6 @@
51
51
  />
52
52
  <AposButton
53
53
  type="primary"
54
- data-apos-focus-priority
55
54
  :label="saveLabel"
56
55
  :disabled="errorCount > 0"
57
56
  @click="save"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "4.10.0",
3
+ "version": "4.11.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -11,12 +11,12 @@ describe('Asset - External Build', function () {
11
11
  this.timeout(t.timeout);
12
12
 
13
13
  after(async function () {
14
- // await fs.remove(publicBuildPath);
14
+ await fs.remove(publicBuildPath);
15
15
  await t.destroy(apos);
16
16
  });
17
17
 
18
18
  beforeEach(async function () {
19
- // await fs.remove(publicBuildPath);
19
+ await fs.remove(publicBuildPath);
20
20
  });
21
21
 
22
22
  it('should register external build module', async function () {
@@ -260,4 +260,185 @@ describe('Asset - External Build', function () {
260
260
  '`build` configuration is not identical to the legacy `webpack` configuration'
261
261
  );
262
262
  });
263
+
264
+ it('should inject custom bundles dynamic CSS (PRO-6904)', async function () {
265
+ await t.destroy(apos);
266
+
267
+ apos = await t.create({
268
+ root: module,
269
+ autoBuild: true,
270
+ modules: {
271
+ 'asset-vite': {
272
+ before: '@apostrophecms/asset',
273
+ handlers(self) {
274
+ return {
275
+ '@apostrophecms/asset:afterInit': {
276
+ async registerExternalBuild() {
277
+ self.apos.asset.configureBuildModule(self, {
278
+ alias: 'vite',
279
+ devServer: false,
280
+ hmr: false
281
+ });
282
+ }
283
+ }
284
+ };
285
+ },
286
+ methods(self) {
287
+ return {
288
+ async build() {
289
+ return {
290
+ entrypoints: []
291
+ };
292
+ },
293
+ async watch() { },
294
+ async startDevServer() {
295
+ return {
296
+ entrypoints: []
297
+ };
298
+ },
299
+ async entrypoints() {
300
+ return [];
301
+ }
302
+ };
303
+ }
304
+ }
305
+ }
306
+ });
307
+
308
+ // Mock the build manifest
309
+ apos.asset.currentBuildManifest = {
310
+ sourceMapsRoot: null,
311
+ devServerUrl: null,
312
+ hmrTypes: [],
313
+ entrypoints: [
314
+ {
315
+ name: 'src',
316
+ type: 'index',
317
+ scenes: [ 'apos', 'public' ],
318
+ outputs: [ 'css', 'js' ],
319
+ condition: 'module',
320
+ manifest: {
321
+ root: 'dist',
322
+ name: 'src',
323
+ devServer: false
324
+ },
325
+ bundles: [
326
+ 'apos-src-module-bundle.js',
327
+ 'apos-bundle.css',
328
+ 'public-src-module-bundle.js',
329
+ 'public-bundle.css'
330
+ ]
331
+ },
332
+ {
333
+ name: 'counter-react',
334
+ type: 'custom',
335
+ scenes: [ 'counter-react' ],
336
+ outputs: [ 'css', 'js' ],
337
+ condition: 'module',
338
+ prologue: '',
339
+ ignoreSources: [],
340
+ manifest: {
341
+ root: 'dist',
342
+ name: 'counter-react',
343
+ devServer: false
344
+ },
345
+ bundles: [ 'counter-react-module-bundle.js' ]
346
+ },
347
+ {
348
+ name: 'counter-svelte',
349
+ type: 'custom',
350
+ scenes: [ 'counter-svelte' ],
351
+ outputs: [ 'css', 'js' ],
352
+ condition: 'module',
353
+ manifest: {
354
+ root: 'dist',
355
+ name: 'counter-svelte',
356
+ devServer: false
357
+ },
358
+ bundles: [
359
+ 'counter-svelte-module-bundle.js',
360
+ 'counter-svelte-bundle.css'
361
+ ]
362
+ },
363
+ {
364
+ name: 'counter-vue',
365
+ type: 'custom',
366
+ scenes: [ 'counter-vue' ],
367
+ outputs: [ 'css', 'js' ],
368
+ condition: 'module',
369
+ manifest: {
370
+ root: 'dist',
371
+ name: 'counter-vue',
372
+ devServer: false
373
+ },
374
+ bundles: [ 'counter-vue-module-bundle.js', 'counter-vue-bundle.css' ]
375
+ },
376
+ {
377
+ name: 'apos',
378
+ type: 'apos',
379
+ scenes: [ 'apos' ],
380
+ outputs: [ 'js' ],
381
+ condition: 'module',
382
+ manifest: {
383
+ root: 'dist',
384
+ name: 'apos',
385
+ devServer: false
386
+ },
387
+ bundles: [ 'apos-module-bundle.js', 'apos-bundle.css' ]
388
+ }
389
+ ]
390
+ };
391
+
392
+ // `counter-svelte` has `ui/src/counter-svelte.scss`.
393
+ // `counter-vue` doesn't have any CSS but has dynamic CSS, extracted
394
+ // from Vue components.
395
+ apos.asset.extraBundles = {
396
+ js: [ 'counter-react', 'counter-svelte', 'counter-vue' ],
397
+ css: [ 'counter-svelte ' ]
398
+ };
399
+ apos.asset.rebundleModules = [];
400
+
401
+ const payload = {
402
+ page: {
403
+ type: '@apostrophecms/home-page'
404
+ },
405
+ scene: 'apos',
406
+ template: '@apostrophecms/home-page:page',
407
+ content: '[stylesheets-placeholder:1]\n[scripts-placeholder:1]',
408
+ scriptsPlaceholder: '[scripts-placeholder:1]',
409
+ stylesheetsPlaceholder: '[stylesheets-placeholder:1]',
410
+ widgetsBundles: { 'counter-vue': {} }
411
+ };
412
+
413
+ const injected = apos.template.insertBundlesMarkup(payload);
414
+
415
+ // All bundles are injected, including the dynamic CSS from `counter-vue`
416
+ const actual = injected.replace(/>\s+</g, '><');
417
+ const expected = '<link rel="stylesheet" href="/apos-frontend/default/apos-bundle.css">' +
418
+ '<link rel="stylesheet" href="/apos-frontend/default/counter-svelte-bundle.css">' +
419
+ '<link rel="stylesheet" href="/apos-frontend/default/counter-vue-bundle.css">' +
420
+ '<script type="module" src="/apos-frontend/default/apos-src-module-bundle.js"></script>' +
421
+ '<script type="module" src="/apos-frontend/default/apos-module-bundle.js"></script>' +
422
+ '<script type="module" src="/apos-frontend/default/counter-react-module-bundle.js"></script>' +
423
+ '<script type="module" src="/apos-frontend/default/counter-svelte-module-bundle.js"></script>' +
424
+ '<script type="module" src="/apos-frontend/default/counter-vue-module-bundle.js"></script>';
425
+
426
+ assert.equal(actual, expected, 'Bundles are not injected correctly');
427
+
428
+ const injectedPublic = apos.template.insertBundlesMarkup({
429
+ ...payload,
430
+ scene: 'public'
431
+ });
432
+
433
+ // Showcases that the only the dynamic Vue CSS is injected, because the widget
434
+ // owning the bundle counter-vue is present.
435
+ const actualPublic = injectedPublic.replace(/>\s+</g, '><');
436
+ const expectedPublic = '<link rel="stylesheet" href="/apos-frontend/default/public-bundle.css">' +
437
+ '<link rel="stylesheet" href="/apos-frontend/default/counter-vue-bundle.css">' +
438
+ '<script type="module" src="/apos-frontend/default/public-src-module-bundle.js"></script>' +
439
+ '<script type="module" src="/apos-frontend/default/counter-vue-module-bundle.js"></script>';
440
+
441
+ assert.equal(actualPublic, expectedPublic, 'Bundles are not injected correctly');
442
+
443
+ });
263
444
  });