@plone/volto 17.20.4 → 17.22.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/.readthedocs.yaml CHANGED
@@ -3,8 +3,7 @@ build:
3
3
  os: "ubuntu-22.04"
4
4
  tools:
5
5
  python: "3.12"
6
- jobs:
7
- post_checkout:
8
- # Cancel the Read the Docs build
9
- # https://docs.readthedocs.io/en/stable/build-customization.html#cancel-build-based-on-a-condition
10
- - exit 183;
6
+ commands:
7
+ # Cancel the Read the Docs build
8
+ # https://docs.readthedocs.io/en/stable/build-customization.html#cancel-build-based-on-a-condition
9
+ - exit 183;
Binary file
package/CHANGELOG.md CHANGED
@@ -17,6 +17,28 @@ myst:
17
17
 
18
18
  <!-- towncrier release notes start -->
19
19
 
20
+ ## 17.22.0 (2025-05-20)
21
+
22
+ ### Feature
23
+
24
+ - Use table sorting icons from pastanaga `icons.woff` instead of assuming we have Font Awesome icons. Improve accessibility for sortable table headers in table block. @ichim-david @kreafox [#7091](https://github.com/plone/volto/issues/7091)
25
+
26
+ ### Bugfix
27
+
28
+ - Added an `aria-label` to `CreatableSelect` inside `TokenWidget` to improve the component’s accessibility. @Wagner3UB [#6786](https://github.com/plone/volto/issues/6786)
29
+
30
+ ## 17.21.0 (2025-03-05)
31
+
32
+ ### Feature
33
+
34
+ - Provide language alternate links @erral [#6616](https://github.com/plone/volto/issues/6616)
35
+
36
+ ### Bugfix
37
+
38
+ - In `RichTextWidget` and `HtmlSlateWidget`, fix breaking a list by typing Enter. @nileshgulia1 [#6570](https://github.com/plone/volto/issues/6570)
39
+ - Display the appropriately sized image to eliminate blurring from upsizing smaller images in the `srcSet` generation. @giuliaghisini [#6637](https://github.com/plone/volto/issues/6637)
40
+ - a11y - Added id attribute to checkbox widget for proper identification and fixes label functionality for screen readers. @Wagner3UB [#6771](https://github.com/plone/volto/issues/6771)
41
+
20
42
  ## 17.20.4 (2025-01-07)
21
43
 
22
44
  ### Bugfix
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  }
10
10
  ],
11
11
  "license": "MIT",
12
- "version": "17.20.4",
12
+ "version": "17.22.0",
13
13
  "repository": {
14
14
  "type": "git",
15
15
  "url": "git@github.com:plone/volto.git"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plone/volto-slate",
3
- "version": "17.20.4",
3
+ "version": "17.22.0",
4
4
  "description": "Slate.js integration with Volto",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -71,6 +71,19 @@ const View = ({ data }) => {
71
71
  });
72
72
  }, [state, rows]);
73
73
 
74
+ const handleSort = (index) => {
75
+ if (!data.table.sortable) return;
76
+ setState({
77
+ column: index,
78
+ direction:
79
+ state.column !== index
80
+ ? 'ascending'
81
+ : state.direction === 'ascending'
82
+ ? 'descending'
83
+ : 'ascending',
84
+ });
85
+ };
86
+
74
87
  return (
75
88
  <>
76
89
  {data && data.table && (
@@ -92,19 +105,20 @@ const View = ({ data }) => {
92
105
  key={cell.key}
93
106
  textAlign="left"
94
107
  verticalAlign="middle"
108
+ tabIndex={data.table.sortable ? '0' : '-1'}
95
109
  sorted={state.column === index ? state.direction : null}
96
110
  onClick={() => {
97
- if (!data.table.sortable) return;
98
- setState({
99
- column: index,
100
- direction:
101
- state.column !== index
102
- ? 'ascending'
103
- : state.direction === 'ascending'
104
- ? 'descending'
105
- : 'ascending',
106
- });
111
+ handleSort(index);
112
+ }}
113
+ onKeyDown={(e) => {
114
+ if (e.key === 'Enter' || e.key === ' ') {
115
+ e.preventDefault();
116
+ handleSort(index);
117
+ }
107
118
  }}
119
+ aria-sort={
120
+ state.column === index ? state.direction : 'none'
121
+ }
108
122
  >
109
123
  {cell.value &&
110
124
  Node.string({ children: cell.value }).length > 0
@@ -0,0 +1,67 @@
1
+ import { Editor, Range, Transforms } from 'slate';
2
+
3
+ import config from '@plone/volto/registry';
4
+ import { isCursorAtBlockEnd } from '@plone/volto-slate/utils/selection';
5
+ import { getCurrentListItem } from '@plone/volto-slate/utils/lists';
6
+ import { createEmptyParagraph } from '@plone/volto-slate/utils/blocks';
7
+
8
+ export const breakListInWidget = (editor) => {
9
+ const { insertBreak } = editor;
10
+
11
+ editor.insertBreak = () => {
12
+ if (!(editor.selection && Range.isCollapsed(editor.selection))) {
13
+ insertBreak();
14
+ return false;
15
+ }
16
+
17
+ const { slate } = config.settings;
18
+ const { anchor } = editor.selection;
19
+
20
+ const ref = Editor.rangeRef(editor, editor.selection, {
21
+ affinity: 'inward',
22
+ });
23
+
24
+ const [listItem, listItemPath] = getCurrentListItem(editor);
25
+ if (listItem) {
26
+ if (Editor.string(editor, listItemPath)) {
27
+ Transforms.splitNodes(editor, {
28
+ at: editor.selection,
29
+ match: (node) => node.type === slate.listItemType,
30
+ always: true,
31
+ });
32
+
33
+ return true;
34
+ }
35
+ }
36
+
37
+ const [parent] = Editor.parent(editor, anchor.path);
38
+
39
+ if (parent.type !== slate.listItemType || anchor.offset > 0) {
40
+ insertBreak();
41
+ return;
42
+ }
43
+
44
+ Editor.deleteBackward(editor, { unit: 'line' });
45
+ // also account for empty nodes [{text: ''}]
46
+ if (Editor.isEmpty(editor, parent)) {
47
+ Transforms.removeNodes(editor, { at: ref.current });
48
+
49
+ Transforms.insertNodes(editor, createEmptyParagraph(), {
50
+ at: [editor.children.length],
51
+ });
52
+ Transforms.select(editor, Editor.end(editor, []));
53
+
54
+ return true;
55
+ }
56
+
57
+ Transforms.removeNodes(editor, { at: ref.current });
58
+
59
+ if (isCursorAtBlockEnd(editor)) {
60
+ Editor.insertNode(editor, createEmptyParagraph());
61
+ return true;
62
+ }
63
+ return true;
64
+ };
65
+
66
+ return editor;
67
+ };
@@ -4,3 +4,4 @@ export * from './breakList';
4
4
  export * from './withLists';
5
5
  export * from './isSelected';
6
6
  export * from './normalizeExternalData';
7
+ export * from './breakListInWidget';
@@ -23,6 +23,7 @@ import {
23
23
  import { withDeleteSelectionOnEnter } from '@plone/volto-slate/editor/extensions';
24
24
  import {
25
25
  breakList,
26
+ breakListInWidget,
26
27
  withDeserializers,
27
28
  withLists,
28
29
  withSplitBlocksOnBreak,
@@ -48,6 +49,7 @@ export default function applyConfig(config) {
48
49
  breakList,
49
50
  normalizeExternalData,
50
51
  ],
52
+ slateWidgetExtensions: [breakListInWidget],
51
53
 
52
54
  // Pluggable handlers for the onKeyDown event of <Editable />
53
55
  // Order matters here. A handler can return `true` to stop executing any
@@ -12,6 +12,7 @@ import { defineMessages, injectIntl } from 'react-intl';
12
12
  import { FormFieldWrapper } from '@plone/volto/components';
13
13
  import SlateEditor from '@plone/volto-slate/editor/SlateEditor';
14
14
  import { serializeNodes } from '@plone/volto-slate/editor/render';
15
+ import { handleKeyDetached } from '@plone/volto-slate/blocks/Text/keyboard';
15
16
  import { makeEditor } from '@plone/volto-slate/utils';
16
17
  import deserialize from '@plone/volto-slate/editor/deserialize';
17
18
 
@@ -19,14 +20,15 @@ import {
19
20
  createEmptyParagraph,
20
21
  normalizeExternalData,
21
22
  } from '@plone/volto-slate/utils';
23
+ import config from '@plone/volto/registry';
24
+
22
25
  import { ErrorBoundary } from './ErrorBoundary';
23
26
 
24
27
  import './style.css';
25
28
 
26
29
  const messages = defineMessages({
27
30
  error: {
28
- id:
29
- 'An error has occurred while editing "{name}" field. We have been notified and we are looking into it. Please save your work and retry. If the issue persists please contact the site administrator.',
31
+ id: 'An error has occurred while editing "{name}" field. We have been notified and we are looking into it. Please save your work and retry. If the issue persists please contact the site administrator.',
30
32
  defaultMessage:
31
33
  'An error has occurred while editing "{name}" field. We have been notified and we are looking into it. Please save your work and retry. If the issue persists please contact the site administrator.',
32
34
  },
@@ -45,6 +47,8 @@ const HtmlSlateWidget = (props) => {
45
47
  intl,
46
48
  } = props;
47
49
 
50
+ const { slateWidgetExtensions } = config.settings.slate;
51
+
48
52
  const [selected, setSelected] = React.useState(focus);
49
53
 
50
54
  const editor = React.useMemo(() => makeEditor(), []);
@@ -128,6 +132,9 @@ const HtmlSlateWidget = (props) => {
128
132
  block={block}
129
133
  selected={selected}
130
134
  properties={properties}
135
+ extensions={slateWidgetExtensions}
136
+ onKeyDown={handleKeyDetached}
137
+ editableProps={{ 'aria-multiline': 'true' }}
131
138
  placeholder={placeholder}
132
139
  />
133
140
  </ErrorBoundary>
@@ -7,7 +7,9 @@ import React from 'react';
7
7
  import isUndefined from 'lodash/isUndefined';
8
8
  import isString from 'lodash/isString';
9
9
  import { FormFieldWrapper } from '@plone/volto/components';
10
+ import { handleKeyDetached } from '@plone/volto-slate/blocks/Text/keyboard';
10
11
  import SlateEditor from '@plone/volto-slate/editor/SlateEditor';
12
+ import config from '@plone/volto/registry';
11
13
 
12
14
  import { createEmptyParagraph, createParagraph } from '../utils/blocks';
13
15
 
@@ -37,6 +39,7 @@ const SlateRichTextWidget = (props) => {
37
39
  readOnly = false,
38
40
  } = props;
39
41
  const [selected, setSelected] = React.useState(focus);
42
+ const { slateWidgetExtensions } = config.settings.slate;
40
43
 
41
44
  return (
42
45
  <FormFieldWrapper {...props} draggable={false} className="slate_wysiwyg">
@@ -62,7 +65,10 @@ const SlateRichTextWidget = (props) => {
62
65
  block={block}
63
66
  selected={selected}
64
67
  properties={properties}
68
+ extensions={slateWidgetExtensions}
69
+ onKeyDown={handleKeyDetached}
65
70
  placeholder={placeholder}
71
+ editableProps={{ 'aria-multiline': 'true' }}
66
72
  />
67
73
  </div>
68
74
  </FormFieldWrapper>
@@ -31,6 +31,7 @@ const CheckboxWidget = (props) => {
31
31
  <FormFieldWrapper {...props} columns={1}>
32
32
  <div className="wrapper">
33
33
  <Checkbox
34
+ id={`field-${id}`}
34
35
  name={`field-${id}`}
35
36
  checked={value || false}
36
37
  disabled={isDisabled}
@@ -173,6 +173,7 @@ class TokenWidget extends Component {
173
173
  <FormFieldWrapper {...this.props}>
174
174
  <CreatableSelect
175
175
  id={`field-${this.props.id}`}
176
+ aria-labelledby={`fieldset-${this.props.fieldSet}-field-label-${this.props.id}`}
176
177
  key={this.props.id}
177
178
  menuShouldScrollIntoView={false}
178
179
  isDisabled={this.props.isDisabled}
@@ -0,0 +1,23 @@
1
+ import config from '@plone/volto/registry';
2
+ import Helmet from '@plone/volto/helpers/Helmet/Helmet';
3
+
4
+ const AlternateHrefLangs = (props) => {
5
+ const { content } = props;
6
+ return (
7
+ <Helmet>
8
+ {config.settings.isMultilingual &&
9
+ content['@components']?.translations?.items?.map((item, key) => {
10
+ return (
11
+ <link
12
+ key={key}
13
+ rel="alternate"
14
+ hrefLang={item.language}
15
+ href={item['@id']}
16
+ />
17
+ );
18
+ })}
19
+ </Helmet>
20
+ );
21
+ };
22
+
23
+ export { AlternateHrefLangs };
@@ -0,0 +1,135 @@
1
+ import React from 'react';
2
+ import Helmet from '@plone/volto/helpers/Helmet/Helmet';
3
+
4
+ import renderer from 'react-test-renderer';
5
+ import configureStore from 'redux-mock-store';
6
+ import { Provider } from 'react-intl-redux';
7
+ import config from '@plone/volto/registry';
8
+
9
+ import { AlternateHrefLangs } from './AlternateHrefLangs';
10
+
11
+ const mockStore = configureStore();
12
+
13
+ describe('AlternateHrefLangs', () => {
14
+ beforeEach(() => {});
15
+ it('non multilingual site, renders nothing', () => {
16
+ config.settings.isMultilingual = false;
17
+ const content = {
18
+ '@id': '/',
19
+ '@components': {},
20
+ };
21
+ const store = mockStore({
22
+ intl: {
23
+ locale: 'en',
24
+ messages: {},
25
+ },
26
+ });
27
+ // We need to force the component rendering
28
+ // to fill the Helmet
29
+ renderer.create(
30
+ <Provider store={store}>
31
+ <AlternateHrefLangs content={content} />
32
+ </Provider>,
33
+ );
34
+
35
+ const helmetLinks = Helmet.peek().linkTags;
36
+ expect(helmetLinks.length).toBe(0);
37
+ });
38
+ it('multilingual site, with some translations', () => {
39
+ config.settings.isMultilingual = true;
40
+ config.settings.supportedLanguages = ['en', 'es', 'eu'];
41
+
42
+ const content = {
43
+ '@components': {
44
+ translations: {
45
+ items: [
46
+ { '@id': '/en', language: 'en' },
47
+ { '@id': '/es', language: 'es' },
48
+ ],
49
+ },
50
+ },
51
+ };
52
+
53
+ const store = mockStore({
54
+ intl: {
55
+ locale: 'en',
56
+ messages: {},
57
+ },
58
+ });
59
+
60
+ // We need to force the component rendering
61
+ // to fill the Helmet
62
+ renderer.create(
63
+ <Provider store={store}>
64
+ <>
65
+ <AlternateHrefLangs content={content} />
66
+ </>
67
+ </Provider>,
68
+ );
69
+ const helmetLinks = Helmet.peek().linkTags;
70
+
71
+ expect(helmetLinks.length).toBe(2);
72
+
73
+ expect(helmetLinks).toContainEqual({
74
+ rel: 'alternate',
75
+ href: '/es',
76
+ hrefLang: 'es',
77
+ });
78
+ expect(helmetLinks).toContainEqual({
79
+ rel: 'alternate',
80
+ href: '/en',
81
+ hrefLang: 'en',
82
+ });
83
+ });
84
+ it('multilingual site, with all available translations', () => {
85
+ config.settings.isMultilingual = true;
86
+ config.settings.supportedLanguages = ['en', 'es', 'eu'];
87
+ const store = mockStore({
88
+ intl: {
89
+ locale: 'en',
90
+ messages: {},
91
+ },
92
+ });
93
+
94
+ const content = {
95
+ '@components': {
96
+ translations: {
97
+ items: [
98
+ { '@id': '/en', language: 'en' },
99
+ { '@id': '/eu', language: 'eu' },
100
+ { '@id': '/es', language: 'es' },
101
+ ],
102
+ },
103
+ },
104
+ };
105
+
106
+ // We need to force the component rendering
107
+ // to fill the Helmet
108
+ renderer.create(
109
+ <Provider store={store}>
110
+ <AlternateHrefLangs content={content} />
111
+ </Provider>,
112
+ );
113
+
114
+ const helmetLinks = Helmet.peek().linkTags;
115
+
116
+ // We expect having 3 links
117
+ expect(helmetLinks.length).toBe(3);
118
+
119
+ expect(helmetLinks).toContainEqual({
120
+ rel: 'alternate',
121
+ href: '/eu',
122
+ hrefLang: 'eu',
123
+ });
124
+ expect(helmetLinks).toContainEqual({
125
+ rel: 'alternate',
126
+ href: '/es',
127
+ hrefLang: 'es',
128
+ });
129
+ expect(helmetLinks).toContainEqual({
130
+ rel: 'alternate',
131
+ href: '/en',
132
+ hrefLang: 'en',
133
+ });
134
+ });
135
+ });
@@ -54,7 +54,14 @@ export default function Image({
54
54
  attrs.className = cx(className, { responsive });
55
55
 
56
56
  if (!isSvg && image.scales && Object.keys(image.scales).length > 0) {
57
- const sortedScales = Object.values(image.scales).sort((a, b) => {
57
+ const sortedScales = Object.values({
58
+ ...image.scales,
59
+ original: {
60
+ download: `${image.download}`,
61
+ width: image.width,
62
+ height: image.height,
63
+ },
64
+ }).sort((a, b) => {
58
65
  if (a.width > b.width) return 1;
59
66
  else if (a.width < b.width) return -1;
60
67
  else return 0;
@@ -18,6 +18,7 @@ import {
18
18
  Tags,
19
19
  Toolbar,
20
20
  } from '@plone/volto/components';
21
+ import { AlternateHrefLangs } from '@plone/volto/components/theme/AlternateHrefLangs/AlternateHrefLangs';
21
22
  import { listActions, getContent } from '@plone/volto/actions';
22
23
  import {
23
24
  BodyClass,
@@ -238,6 +239,7 @@ class View extends Component {
238
239
  return (
239
240
  <div id="view">
240
241
  <ContentMetadataTags content={this.props.content} />
242
+ <AlternateHrefLangs content={this.props.content} />
241
243
  {/* Body class if displayName in component is set */}
242
244
  <BodyClass
243
245
  className={
@@ -19,6 +19,15 @@
19
19
  vertical-align: @headerVerticalAlign;
20
20
  }
21
21
 
22
+ // use sorting icons from icons.woff instead of assuming it's font awesome
23
+ .ui.sortable.table thead th.ascending::after {
24
+ content: '\E9EC';
25
+ }
26
+
27
+ .ui.sortable.table thead th.descending::after {
28
+ content: '\E9EB';
29
+ }
30
+
22
31
  .ui.table tr > th:first-child {
23
32
  border-left: none;
24
33
  }
@@ -0,0 +1 @@
1
+ export function AlternateHrefLangs(props: any): JSX.Element;