apostrophe 4.30.0-alpha.1 → 4.30.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 (64) hide show
  1. package/.claude/settings.local.json +15 -0
  2. package/CHANGELOG.md +30 -2
  3. package/eslint.config.js +1 -2
  4. package/lib/mongodb-connect.js +62 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +25 -8
  7. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBreakpointPreviewMode.vue +9 -0
  8. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +20 -2
  9. package/modules/@apostrophecms/area/index.js +10 -5
  10. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -0
  11. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +11 -1
  12. package/modules/@apostrophecms/db/index.js +27 -68
  13. package/modules/@apostrophecms/http/index.js +1 -1
  14. package/modules/@apostrophecms/i18n/i18n/en.json +8 -0
  15. package/modules/@apostrophecms/i18n/index.js +1 -8
  16. package/modules/@apostrophecms/image-widget/index.js +29 -1
  17. package/modules/@apostrophecms/job/index.js +7 -9
  18. package/modules/@apostrophecms/layout-widget/index.js +124 -2
  19. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +89 -6
  20. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +2 -2
  21. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +2 -2
  22. package/modules/@apostrophecms/layout-widget/ui/apos/layout.css +8 -0
  23. package/modules/@apostrophecms/layout-widget/ui/src/layout.css +1 -1
  24. package/modules/@apostrophecms/layout-widget/views/widget.html +3 -3
  25. package/modules/@apostrophecms/login/index.js +13 -15
  26. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +2 -1
  27. package/modules/@apostrophecms/oembed/index.js +18 -13
  28. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +2 -0
  29. package/modules/@apostrophecms/rich-text-widget/index.js +36 -0
  30. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +1 -1
  31. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +1 -1
  32. package/modules/@apostrophecms/styles/index.js +16 -0
  33. package/modules/@apostrophecms/styles/lib/handlers.js +6 -0
  34. package/modules/@apostrophecms/styles/lib/methods.js +93 -0
  35. package/modules/@apostrophecms/styles/lib/presets.js +17 -0
  36. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +10 -1
  37. package/modules/@apostrophecms/submitted-draft/ui/apos/components/AposSubmittedDraftIcon.vue +1 -0
  38. package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -1
  39. package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +14 -2
  40. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +29 -5
  41. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +8 -0
  42. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuItem.vue +5 -2
  43. package/modules/@apostrophecms/ui/ui/apos/components/AposLocalePicker.vue +14 -1
  44. package/modules/@apostrophecms/ui/ui/apos/scss/global/_utilities.scss +13 -1
  45. package/modules/@apostrophecms/util/index.js +4 -0
  46. package/modules/@apostrophecms/widget-type/index.js +6 -0
  47. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +32 -0
  48. package/package.json +13 -13
  49. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
  50. package/test/add-missing-schema-fields-project/test.js +3 -11
  51. package/test/assets.js +67 -110
  52. package/test/db.js +15 -24
  53. package/test/job.js +1 -1
  54. package/test/layout-widget-gap.js +530 -0
  55. package/test/login.js +122 -1
  56. package/test/rich-text-widget.js +200 -0
  57. package/test/styles.js +50 -0
  58. package/test-lib/util.js +14 -50
  59. package/claude-tools/detect-handles.js +0 -46
  60. package/claude-tools/minimal-hang-test.js +0 -28
  61. package/claude-tools/mongo-close-test.js +0 -11
  62. package/claude-tools/stdin-ref-test.js +0 -14
  63. package/test/db-tools.js +0 -365
  64. package/test/default-adapter.js +0 -256
@@ -128,6 +128,10 @@ module.exports = {
128
128
  defaultData: { content: '' },
129
129
  className: false,
130
130
  linkWithType: [ '@apostrophecms/any-page-type' ],
131
+ // Hostnames from which `<img>` tags in `import.html` may be fetched.
132
+ // The list is empty by default, which disables image fetching during
133
+ // rich text HTML import. Add hostnames here to opt in.
134
+ imageImportAllowedHostnames: [],
131
135
  tableOptions: {
132
136
  resizable: true,
133
137
  handleWidth: 10,
@@ -472,6 +476,31 @@ module.exports = {
472
476
  return widget.content;
473
477
  },
474
478
 
479
+ // Return the configured allowlist of hostnames from which images may
480
+ // be fetched during a rich text widget HTML import, normalized to
481
+ // lowercase. Returns an empty array if the option is unset, in which
482
+ // case any image fetch attempted during import is rejected.
483
+ getImageImportAllowedHostnames() {
484
+ const list = self.options.imageImportAllowedHostnames;
485
+ if (!Array.isArray(list)) {
486
+ return [];
487
+ }
488
+ return list
489
+ .filter(entry => typeof entry === 'string' && entry.length > 0)
490
+ .map(entry => entry.toLowerCase());
491
+ },
492
+
493
+ // Determine whether the given URL's hostname is permitted by the
494
+ // import allowlist. The protocol is also restricted to http/https
495
+ // to prevent fetches via file:, data:, or other schemes.
496
+ isImageImportHostnameAllowed(url, allowedHostnames) {
497
+ if (!url || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
498
+ return false;
499
+ }
500
+ const hostname = (url.hostname || '').toLowerCase();
501
+ return allowedHostnames.includes(hostname);
502
+ },
503
+
475
504
  // Handle relationships to permalinks and inline images
476
505
  async load(req, widgets) {
477
506
  try {
@@ -1057,6 +1086,7 @@ module.exports = {
1057
1086
  if (input.import.baseUrl && ((typeof input.import.html) !== 'string')) {
1058
1087
  throw self.apos.error('invalid', 'If present, import.baseUrl must be a string');
1059
1088
  }
1089
+ const allowedHostnames = self.getImageImportAllowedHostnames();
1060
1090
  const $ = cheerio.load(input.import.html);
1061
1091
  const $images = $('img');
1062
1092
  // Build an array of cheerio objects because
@@ -1071,6 +1101,12 @@ module.exports = {
1071
1101
  const src = $image.attr('src');
1072
1102
  const alt = $image.attr('alt') && self.apos.util.escapeHtml($image.attr('alt'));
1073
1103
  const url = new URL(src, input.import.baseUrl || self.apos.baseUrl);
1104
+ if (!self.isImageImportHostnameAllowed(url, allowedHostnames)) {
1105
+ throw self.apos.error(
1106
+ 'forbidden',
1107
+ `Refusing to import image from disallowed hostname "${url.hostname}". Add it to the \`imageImportAllowedHostnames\` option of the \`@apostrophecms/rich-text-widget\` module to allow it.`
1108
+ );
1109
+ }
1074
1110
  const res = await fetch(url);
1075
1111
  if (res.status >= 400) {
1076
1112
  self.apos.util.warn(`Error ${res.status} while importing ${src}, ignoring image`);
@@ -11,7 +11,7 @@ export default {
11
11
  },
12
12
  methods: {
13
13
  getChoiceId(uid, value) {
14
- return (uid + JSON.stringify(value)).replace(/\s+/g, '');
14
+ return (uid + String(value)).replace(/[\s"'.]/g, '');
15
15
  },
16
16
  watchValue () {
17
17
  this.error = this.modelValue.error;
@@ -8,7 +8,7 @@ export default {
8
8
  mixins: [ AposInputMixin, AposInputChoicesMixin ],
9
9
  methods: {
10
10
  getChoiceId(uid, value) {
11
- return (uid + JSON.stringify(value)).replace(/\s+/g, '');
11
+ return (uid + String(value)).replace(/[\s"'.]/g, '');
12
12
  },
13
13
  validate(value) {
14
14
  const validValue = this.choices.some((choice) => choice.value === value);
@@ -87,6 +87,22 @@ module.exports = {
87
87
  self.prependNodes('body', 'stylesheet');
88
88
  self.prependNodes('body', 'ui');
89
89
 
90
+ // Detect the layout gap field (carries `layoutGapDefault: true`,
91
+ // see the `layoutGap` preset). If present, layout-widget will use its
92
+ // value as the site-wide layout gap. Only one such field is allowed —
93
+ // the first match wins; subsequent ones are ignored with a warning.
94
+ const layoutGapFieldNames = self.fieldsWithMarker(
95
+ self.schema, 'layoutGapDefault'
96
+ );
97
+ if (layoutGapFieldNames.length > 1) {
98
+ self.apos.util.warn(
99
+ '[@apostrophecms/styles] Multiple fields are marked with ' +
100
+ `\`layoutGapDefault: true\` (${layoutGapFieldNames.join(', ')}). ` +
101
+ 'Only the first one will be used.'
102
+ );
103
+ }
104
+ self.layoutGapFieldName = layoutGapFieldNames[0] || null;
105
+
90
106
  // Detect if any top-level style field uses a background preset
91
107
  // (which includes image relationships requiring attachment annotation).
92
108
  // A hack until we analyze the relationship/attachment racing
@@ -34,6 +34,12 @@ node app @apostrophecms-pro/palette:migrate-to-styles
34
34
  stylesClasses: classes,
35
35
  stylesStylesheetVersion: createId()
36
36
  };
37
+ if (self.layoutGapFieldName) {
38
+ const value = self.apos.util.get(doc, self.layoutGapFieldName);
39
+ $set.aposLayoutGap = (value === null || value === undefined || value === '')
40
+ ? null
41
+ : value;
42
+ }
37
43
  return self.apos.doc.db.updateOne({
38
44
  type: '@apostrophecms/global',
39
45
  aposLocale: doc.aposLocale
@@ -353,6 +353,99 @@ module.exports = (self, options) => {
353
353
  throw new Error('Preset must be an object with a "type" property.');
354
354
  }
355
355
  },
356
+ // Walk an expanded styles schema (array of fields) and return the
357
+ // names of fields that emit the given CSS `property`. Recurses into
358
+ // object fields. Returns [] when no schema is provided.
359
+ // The field names are dot notation paths.
360
+ fieldsWithProperty(schema, property) {
361
+ if (!Array.isArray(schema) || !property) {
362
+ return [];
363
+ }
364
+ const matches = [];
365
+ for (const field of schema) {
366
+ const props = Array.isArray(field.property)
367
+ ? field.property
368
+ : (field.property ? [ field.property ] : []);
369
+ if (props.includes(property)) {
370
+ matches.push(field.name);
371
+ }
372
+ if (Array.isArray(field.schema) && field.schema.length) {
373
+ for (const sub of field.schema) {
374
+ const subProps = Array.isArray(sub.property)
375
+ ? sub.property
376
+ : (sub.property ? [ sub.property ] : []);
377
+ if (subProps.includes(property)) {
378
+ matches.push(`${field.name}.${sub.name}`);
379
+ }
380
+ }
381
+ }
382
+ }
383
+ return matches;
384
+ },
385
+ // Resolve a (possibly dotted) field path against an expanded styles
386
+ // schema and return the corresponding field definition, or null if
387
+ // not found. Supports one level of nesting through `object` fields,
388
+ // matching what `fieldsWithProperty` enumerates.
389
+ getFieldByPath(schema, path) {
390
+ if (!Array.isArray(schema) || !path) {
391
+ return null;
392
+ }
393
+ const segments = String(path).split('.');
394
+ let current = schema;
395
+ let field = null;
396
+ for (const segment of segments) {
397
+ if (!Array.isArray(current)) {
398
+ return null;
399
+ }
400
+ field = current.find(f => f.name === segment) || null;
401
+ if (!field) {
402
+ return null;
403
+ }
404
+ current = field.schema || [];
405
+ }
406
+ return field;
407
+ },
408
+ // Walk an expanded styles schema (array of fields) and return the
409
+ // names of fields carrying a given marker property set to `true`.
410
+ // Recurses into object fields (returns dotted paths for nested
411
+ // matches), matching the recursion shape of `fieldsWithProperty`.
412
+ fieldsWithMarker(schema, markerName) {
413
+ if (!Array.isArray(schema) || !markerName) {
414
+ return [];
415
+ }
416
+ const matches = [];
417
+ for (const field of schema) {
418
+ if (field[markerName] === true) {
419
+ matches.push(field.name);
420
+ }
421
+ if (Array.isArray(field.schema) && field.schema.length) {
422
+ for (const sub of field.schema) {
423
+ if (sub[markerName] === true) {
424
+ matches.push(`${field.name}.${sub.name}`);
425
+ }
426
+ }
427
+ }
428
+ }
429
+ return matches;
430
+ },
431
+ // Throw if any field in the given expanded schema is marked with
432
+ // `layoutGapDefault: true`. The `layoutGap` preset is reserved for
433
+ // @apostrophecms/styles schema.
434
+ rejectLayoutGapPresetOnSchema(schema, contextLabel) {
435
+ if (!Array.isArray(schema)) {
436
+ return;
437
+ }
438
+ const offenders = self.fieldsWithMarker(schema, 'layoutGapDefault');
439
+ if (offenders.length) {
440
+ throw new Error(
441
+ `${contextLabel}: the "layoutGap" preset (or a field carrying ` +
442
+ '`layoutGapDefault: true`) is reserved for the @apostrophecms/styles ' +
443
+ 'module and cannot be used as a widget styles field. ' +
444
+ 'Use a field with `property: "gap"` instead. ' +
445
+ `Offending field(s): ${offenders.join(', ')}.`
446
+ );
447
+ }
448
+ },
356
449
  addToAdminBar() {
357
450
  if (Object.keys(self.styles).length === 0) {
358
451
  return;
@@ -298,6 +298,23 @@ module.exports = (moduleOptions) => {
298
298
  }
299
299
  }
300
300
  }
301
+ },
302
+ // Site-wide layout gap for @apostrophecms/layout-widget instances.
303
+ // Writes a CSS custom property at :root; meant to be added to the
304
+ // @apostrophecms/styles module only.
305
+ // Use on widget styles is rejected at boot.
306
+ layoutGap: {
307
+ label: 'apostrophe:styleLayoutGap',
308
+ help: 'apostrophe:styleLayoutGapHelp',
309
+ type: 'range',
310
+ min: 0,
311
+ max: 64,
312
+ def: 24,
313
+ unit: 'px',
314
+ property: '--apos-layout-gap',
315
+ selector: ':root',
316
+ // Marker used to identify the site-wide layout gap field.
317
+ layoutGapDefault: true
301
318
  }
302
319
  };
303
320
  };
@@ -369,7 +369,11 @@ function filter(field, doc) {
369
369
  return true;
370
370
  }
371
371
  if (!doc[field.name] && doc[field.name] !== 0) {
372
- return false;
372
+ // Allow the field through when it declares a `def` so the
373
+ // normalizer can substitute the default.
374
+ if (field.def === null || field.def === undefined || field.def === '') {
375
+ return false;
376
+ }
373
377
  }
374
378
  if (field.customType) {
375
379
  return true;
@@ -437,6 +441,11 @@ function normalize(field, doc, {
437
441
  let selectors = [];
438
442
  let properties = [];
439
443
  let fieldValue = doc[field.name];
444
+ // Fall back to the field's declared `def` when the doc carries no
445
+ // value.
446
+ if (fieldValue === null || fieldValue === undefined) {
447
+ fieldValue = field.def;
448
+ }
440
449
  let canBeInline = true;
441
450
  const fieldUnit = field.unit || '';
442
451
  const fieldMediaQuery = field.mediaQuery || rootMediaQuery;
@@ -7,6 +7,7 @@
7
7
  :tooltip="tooltip"
8
8
  :icon-only="true"
9
9
  :action="action"
10
+ :attrs="{ 'aria-label': $t(tooltip) }"
10
11
  @click="open"
11
12
  >
12
13
  <template #label>
@@ -2,6 +2,7 @@
2
2
  <html lang="{% block locale %}{{ data.locale }}{% endblock %}" dir="{% block direction %}{{ data.i18n.direction or 'ltr' }}{% endblock %}" {% block extraHtml %}{% endblock %}>
3
3
  <head>
4
4
  {% block encoding %}
5
+ {# Per spec (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta#charset) the only allowed value for this attribute is `utf-8` and this meta element must be in the first 1kb of the document #}
5
6
  <meta charset="utf-8">
6
7
  {% endblock %}
7
8
  {% block startHead %}
@@ -15,7 +16,6 @@
15
16
 
16
17
  {% block standardHead %}
17
18
  <meta name="viewport" content="width=device-width, initial-scale=1">
18
- <meta charset="{{ apos.i18n.encoding() }}">
19
19
  {% endblock %}
20
20
  {% component '@apostrophecms/template:inject' with { where: 'head', end: 'append', when: 'hmr' } %}
21
21
  {% component '@apostrophecms/template:inject' with { where: 'head', end: 'append' } %}
@@ -459,6 +459,15 @@ export default {
459
459
  }
460
460
  }
461
461
 
462
+ // Keyboard-focus indicator for the `quiet` modifier
463
+ .apos-button--quiet {
464
+ &:focus-visible {
465
+ box-shadow: 0 0 0 2px var(--a-primary-light-40);
466
+ outline: none;
467
+ border-radius: var(--a-border-radius);
468
+ }
469
+ }
470
+
462
471
  .apos-button--subtle {
463
472
  padding: 11px 10px; // extra pixel keeps them aligned with border'd buttons
464
473
  color: var(--a-text-primary);
@@ -757,11 +766,14 @@ export default {
757
766
  }
758
767
 
759
768
  .apos-button--no-motion {
760
- &:hover:not([disabled]),
761
- &:focus:not([disabled]) {
769
+ &:hover:not([disabled]) {
762
770
  transform: none;
763
771
  box-shadow: none;
764
772
  }
773
+
774
+ &:focus:not([disabled]) {
775
+ transform: none;
776
+ }
765
777
  }
766
778
 
767
779
  .apos-button__wrapper {
@@ -19,15 +19,11 @@
19
19
  v-bind="button"
20
20
  ref="dropdownButton"
21
21
  class="apos-context-menu__btn"
22
- role="button"
23
22
  :data-apos-test="identifier"
24
23
  :state="buttonState"
25
24
  :disabled="disabled"
26
25
  :tooltip="btnTooltip"
27
- :attrs="{
28
- 'aria-haspopup': 'menu',
29
- 'aria-expanded': isOpen ? true : false
30
- }"
26
+ :attrs="triggerAttrs"
31
27
  @icon="setIconToCenterTo"
32
28
  @click.stop="buttonClicked($event)"
33
29
  />
@@ -52,6 +48,7 @@
52
48
  :active-item="activeItem"
53
49
  :is-open="isOpen"
54
50
  :has-tip="hasTip"
51
+ :dialog-label="dialogLabel"
55
52
  @item-clicked="menuItemClicked"
56
53
  @set-arrow="setArrow"
57
54
  >
@@ -87,6 +84,7 @@
87
84
  :is-open="isOpen"
88
85
  :has-tip="hasTip"
89
86
  :ignore-unfocus="ignoreUnfocus"
87
+ :dialog-label="dialogLabel"
90
88
  @item-clicked="menuItemClicked"
91
89
  @set-arrow="setArrow"
92
90
  >
@@ -215,6 +213,21 @@ const props = defineProps({
215
213
  ignoreUnfocus: {
216
214
  type: Boolean,
217
215
  default: false
216
+ },
217
+ // Accessible name for the popover dialog. Pass an i18n key describing
218
+ // the menu's purpose. Defaults to a generic "Menu" label.
219
+ dialogLabel: {
220
+ type: String,
221
+ default: 'apostrophe:menu'
222
+ },
223
+ // Optional accessible name for the trigger button. When provided, it
224
+ // overrides the visible button label as the button's accessible name
225
+ // (useful when the visible label alone does not convey the trigger's
226
+ // purpose, e.g. when the label only shows the current value of a
227
+ // setting the menu changes). Pass an already-translated string.
228
+ triggerAriaLabel: {
229
+ type: String,
230
+ default: null
218
231
  }
219
232
  });
220
233
 
@@ -320,6 +333,17 @@ const menuAttrs = computed(() => {
320
333
  };
321
334
  });
322
335
 
336
+ const triggerAttrs = computed(() => {
337
+ const attrs = {
338
+ 'aria-haspopup': 'menu',
339
+ 'aria-expanded': isOpen.value
340
+ };
341
+ if (props.triggerAriaLabel) {
342
+ attrs['aria-label'] = props.triggerAriaLabel;
343
+ }
344
+ return attrs;
345
+ });
346
+
323
347
  const teleportedStyle = computed(() => {
324
348
  // For teleported content, we need to ensure positioning is always fresh
325
349
  // The positioning is already calculated correctly by setDropdownPosition
@@ -3,6 +3,7 @@
3
3
  class="apos-primary-scrollbar apos-context-menu__dialog"
4
4
  :class="classes"
5
5
  role="dialog"
6
+ :aria-label="$t(props.dialogLabel)"
6
7
  :data-apos-ignore-unfocus="props.ignoreUnfocus"
7
8
  >
8
9
  <AposContextMenuTip
@@ -71,6 +72,13 @@ const props = defineProps({
71
72
  activeItem: {
72
73
  type: String,
73
74
  default: null
75
+ },
76
+ // Accessible name for the dialog wrapper (role="dialog"). Pass an i18n
77
+ // key describing the menu's purpose for the most useful screen-reader
78
+ // announcement. Defaults to a generic "Menu" label.
79
+ dialogLabel: {
80
+ type: String,
81
+ default: 'apostrophe:menu'
74
82
  }
75
83
  });
76
84
 
@@ -2,6 +2,7 @@
2
2
  <li
3
3
  class="apos-context-menu__item"
4
4
  :class="menuItem.separator ? 'apos-context-menu__item--separator' : null"
5
+ role="presentation"
5
6
  >
6
7
  <hr
7
8
  v-if="menuItem.separator"
@@ -178,7 +179,9 @@ export default {
178
179
  }
179
180
 
180
181
  &--disabled {
181
- color: var(--a-base-3);
182
+ // Use --a-base-2 (#6f6f6f) so the disabled label still meets WCAG AA
183
+ // contrast against the menu background (--a-background-primary, #fff).
184
+ color: var(--a-base-2);
182
185
 
183
186
  &:focus,
184
187
  &:active {
@@ -189,7 +192,7 @@ export default {
189
192
  &:focus,
190
193
  &:active {
191
194
  cursor: not-allowed;
192
- color: var(--a-base-5);
195
+ color: var(--a-base-2);
193
196
  background-color: var(--a-base-9);
194
197
  }
195
198
  }
@@ -9,9 +9,14 @@
9
9
  type="text"
10
10
  class="apos-locales-picker__filter"
11
11
  :placeholder="$t('apostrophe:searchLocalesPlaceholder')"
12
+ :aria-label="$t('apostrophe:searchLocales')"
12
13
  >
13
14
  </div>
14
- <ul class="apos-locales-picker__list">
15
+ <ul
16
+ class="apos-locales-picker__list"
17
+ role="menu"
18
+ :aria-label="$t('apostrophe:locale')"
19
+ >
15
20
  <li
16
21
  v-for="locale in filteredLocales"
17
22
  :key="locale.name"
@@ -19,11 +24,15 @@
19
24
  :class="localeClasses(locale)"
20
25
  class="apos-locale-picker__item"
21
26
  data-apos-test="localeItem"
27
+ role="presentation"
22
28
  @click="switchLocale(locale)"
23
29
  >
24
30
  <button
25
31
  :tabindex="isOpen ? '0' : '-1'"
26
32
  class="apos-locale-picker__locale-display"
33
+ role="menuitemradio"
34
+ :aria-checked="isActive(locale)"
35
+ :aria-disabled="isForbidden(locale) ? 'true' : null"
27
36
  >
28
37
  <AposIndicator
29
38
  v-if="isForbidden(locale)"
@@ -49,6 +58,10 @@
49
58
  v-if="showLocalized"
50
59
  class="apos-locale-picker__localized"
51
60
  :class="{ 'apos-state-is-localized': isLocalized(locale) }"
61
+ role="img"
62
+ :aria-label="isLocalized(locale)
63
+ ? $t('apostrophe:localizeLocalized')
64
+ : $t('apostrophe:localizeNotYetLocalized')"
52
65
  />
53
66
  </button>
54
67
  </li>
@@ -1,6 +1,18 @@
1
+ // Visually hidden but exposed to assistive technology and the
2
+ // accessibility tree (unlike `visibility: hidden` or `display: none`,
3
+ // which both remove the element from the a11y tree). Used to provide
4
+ // accessible names for icon-only buttons, hidden labels, and visually
5
+ // hidden form inputs that should remain focusable.
1
6
  .apos-sr-only {
2
- visibility: hidden;
3
7
  position: absolute;
8
+ overflow: hidden;
9
+ width: 1px;
10
+ height: 1px;
11
+ margin: -1px;
12
+ padding: 0;
13
+ border: 0;
14
+ clip: rect(0, 0, 0, 0);
15
+ white-space: nowrap;
4
16
  }
5
17
 
6
18
  .apos-ltr {
@@ -743,6 +743,7 @@ module.exports = {
743
743
  // in addition if the first component begins with `@xyz` the
744
744
  // sub-object within `o` with an `_id` property equal to `xyz`.
745
745
  // is found and returned, no matter how deeply nested it is.
746
+ // Returns `undefined` if any intermediate segment is null or undefined.
746
747
  get(o, path) {
747
748
  let i;
748
749
  path = path.split('.');
@@ -751,6 +752,9 @@ module.exports = {
751
752
  if ((i === 0) && (p.charAt(0) === '@')) {
752
753
  o = self.apos.util.findNestedObjectById(o, p.substring(1));
753
754
  } else {
755
+ if (o == null) {
756
+ return undefined;
757
+ }
754
758
  o = o[p];
755
759
  }
756
760
  }
@@ -355,6 +355,12 @@ module.exports = {
355
355
  addFields: self.apos.schema.fieldsToArray(`Module ${self.__meta.name}`, self.fields),
356
356
  arrangeFields: self.apos.schema.groupsToArray(self.fieldsGroups)
357
357
  }, self);
358
+ // The `layoutGap` preset (and any field carrying
359
+ // `layoutGapDefault: true`) is reserved for global styles.
360
+ self.apos.styles.rejectLayoutGapPresetOnSchema(
361
+ self.schema,
362
+ `Widget "${self.name}"`
363
+ );
358
364
  const forbiddenFields = [
359
365
  '_id',
360
366
  'type'
@@ -371,6 +371,9 @@ export default {
371
371
  this.areaDebounceUpdate.cancel?.();
372
372
  apos.area.widgetOptions = apos.area.widgetOptions.slice(1);
373
373
  this.stopWatchingWindowSize();
374
+ // Publish-side cleanup: tell live-preview consumers we're gone.
375
+ // Safe broadcast — consumers self-gate on widget id.
376
+ this.emitLivePreviewEnd('unmount');
374
377
  },
375
378
  created() {
376
379
  const defaults = this.getDefault();
@@ -561,6 +564,17 @@ export default {
561
564
  subset: this.moduleOptions.stylesFields
562
565
  });
563
566
  this.applyPreviewStyles(styles);
567
+ // Publish the live snapshot on the apos bus so consumers
568
+ // can react in real time.
569
+ // Gated by the widget module's `subscribesToLivePreview` browser-data
570
+ // flag so the bus stays quiet for widgets that don't subscribe.
571
+ if (this.moduleOptions.subscribesToLivePreview) {
572
+ apos.bus.$emit('apos-widget-live-preview', {
573
+ widgetId: this.getPreviewWidgetId(),
574
+ type: this.type,
575
+ data: value.data
576
+ });
577
+ }
564
578
 
565
579
  return;
566
580
  }
@@ -574,6 +588,7 @@ export default {
574
588
  }
575
589
  },
576
590
  removePreview() {
591
+ this.emitLivePreviewEnd(this.saving ? 'save' : 'cancel');
577
592
  if (!this.preview) {
578
593
  return;
579
594
  }
@@ -589,6 +604,20 @@ export default {
589
604
  });
590
605
  }
591
606
  },
607
+ emitLivePreviewEnd(reason) {
608
+ if (!this.moduleOptions.subscribesToLivePreview) {
609
+ return;
610
+ }
611
+ const widgetId = this.getPreviewWidgetId();
612
+ if (!widgetId) {
613
+ return;
614
+ }
615
+ apos.bus.$emit('apos-widget-live-preview-end', {
616
+ widgetId,
617
+ type: this.type,
618
+ reason
619
+ });
620
+ },
592
621
  applyPreviewStyles({
593
622
  inline = '', css = '', classes = []
594
623
  }) {
@@ -715,6 +744,9 @@ export default {
715
744
  };
716
745
  },
717
746
  getPreviewWidgetId() {
747
+ if (!this.preview) {
748
+ return null;
749
+ }
718
750
  if (!this.previewWidgetId) {
719
751
  if (this.preview.create) {
720
752
  // Deliberately different from the final widget's id, which will