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.
- package/.claude/settings.local.json +15 -0
- package/CHANGELOG.md +30 -2
- package/eslint.config.js +1 -2
- package/lib/mongodb-connect.js +62 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -1
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +25 -8
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBreakpointPreviewMode.vue +9 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +20 -2
- package/modules/@apostrophecms/area/index.js +10 -5
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -0
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +11 -1
- package/modules/@apostrophecms/db/index.js +27 -68
- package/modules/@apostrophecms/http/index.js +1 -1
- package/modules/@apostrophecms/i18n/i18n/en.json +8 -0
- package/modules/@apostrophecms/i18n/index.js +1 -8
- package/modules/@apostrophecms/image-widget/index.js +29 -1
- package/modules/@apostrophecms/job/index.js +7 -9
- package/modules/@apostrophecms/layout-widget/index.js +124 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +89 -6
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +2 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +2 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/layout.css +8 -0
- package/modules/@apostrophecms/layout-widget/ui/src/layout.css +1 -1
- package/modules/@apostrophecms/layout-widget/views/widget.html +3 -3
- package/modules/@apostrophecms/login/index.js +13 -15
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +2 -1
- package/modules/@apostrophecms/oembed/index.js +18 -13
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +2 -0
- package/modules/@apostrophecms/rich-text-widget/index.js +36 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +1 -1
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +1 -1
- package/modules/@apostrophecms/styles/index.js +16 -0
- package/modules/@apostrophecms/styles/lib/handlers.js +6 -0
- package/modules/@apostrophecms/styles/lib/methods.js +93 -0
- package/modules/@apostrophecms/styles/lib/presets.js +17 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +10 -1
- package/modules/@apostrophecms/submitted-draft/ui/apos/components/AposSubmittedDraftIcon.vue +1 -0
- package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +14 -2
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +29 -5
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +8 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuItem.vue +5 -2
- package/modules/@apostrophecms/ui/ui/apos/components/AposLocalePicker.vue +14 -1
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_utilities.scss +13 -1
- package/modules/@apostrophecms/util/index.js +4 -0
- package/modules/@apostrophecms/widget-type/index.js +6 -0
- package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +32 -0
- package/package.json +13 -13
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
- package/test/add-missing-schema-fields-project/test.js +3 -11
- package/test/assets.js +67 -110
- package/test/db.js +15 -24
- package/test/job.js +1 -1
- package/test/layout-widget-gap.js +530 -0
- package/test/login.js +122 -1
- package/test/rich-text-widget.js +200 -0
- package/test/styles.js +50 -0
- package/test-lib/util.js +14 -50
- package/claude-tools/detect-handles.js +0 -46
- package/claude-tools/minimal-hang-test.js +0 -28
- package/claude-tools/mongo-close-test.js +0 -11
- package/claude-tools/stdin-ref-test.js +0 -14
- package/test/db-tools.js +0 -365
- 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`);
|
|
@@ -8,7 +8,7 @@ export default {
|
|
|
8
8
|
mixins: [ AposInputMixin, AposInputChoicesMixin ],
|
|
9
9
|
methods: {
|
|
10
10
|
getChoiceId(uid, value) {
|
|
11
|
-
return (uid +
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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-
|
|
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
|
|
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
|