@plone/volto 18.33.1 → 18.34.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 (154) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +0 -1
  3. package/locales/af/LC_MESSAGES/volto.po +25 -0
  4. package/locales/af.json +1 -1
  5. package/locales/ar/LC_MESSAGES/volto.po +25 -0
  6. package/locales/ar.json +1 -1
  7. package/locales/bg/LC_MESSAGES/volto.po +25 -0
  8. package/locales/bg.json +1 -1
  9. package/locales/bn/LC_MESSAGES/volto.po +25 -0
  10. package/locales/bn.json +1 -1
  11. package/locales/ca/LC_MESSAGES/volto.po +25 -0
  12. package/locales/ca.json +1 -1
  13. package/locales/cs/LC_MESSAGES/volto.po +25 -0
  14. package/locales/cs.json +1 -1
  15. package/locales/cy/LC_MESSAGES/volto.po +25 -0
  16. package/locales/cy.json +1 -1
  17. package/locales/da/LC_MESSAGES/volto.po +25 -0
  18. package/locales/da.json +1 -1
  19. package/locales/de/LC_MESSAGES/volto.po +25 -0
  20. package/locales/de.json +1 -1
  21. package/locales/el/LC_MESSAGES/volto.po +25 -0
  22. package/locales/el.json +1 -1
  23. package/locales/en/LC_MESSAGES/volto.po +25 -0
  24. package/locales/en.json +1 -1
  25. package/locales/en_AU/LC_MESSAGES/volto.po +25 -0
  26. package/locales/en_AU.json +1 -1
  27. package/locales/en_GB/LC_MESSAGES/volto.po +25 -0
  28. package/locales/en_GB.json +1 -1
  29. package/locales/eo/LC_MESSAGES/volto.po +25 -0
  30. package/locales/eo.json +1 -1
  31. package/locales/es/LC_MESSAGES/volto.po +42 -17
  32. package/locales/es.json +1 -1
  33. package/locales/et/LC_MESSAGES/volto.po +25 -0
  34. package/locales/et.json +1 -1
  35. package/locales/eu/LC_MESSAGES/volto.po +25 -0
  36. package/locales/eu.json +1 -1
  37. package/locales/fa/LC_MESSAGES/volto.po +25 -0
  38. package/locales/fa.json +1 -1
  39. package/locales/fi/LC_MESSAGES/volto.po +25 -0
  40. package/locales/fi.json +1 -1
  41. package/locales/fr/LC_MESSAGES/volto.po +25 -0
  42. package/locales/fr.json +1 -1
  43. package/locales/fu/LC_MESSAGES/volto.po +25 -0
  44. package/locales/fu.json +1 -1
  45. package/locales/gl/LC_MESSAGES/volto.po +30 -5
  46. package/locales/gl.json +1 -1
  47. package/locales/he/LC_MESSAGES/volto.po +25 -0
  48. package/locales/he.json +1 -1
  49. package/locales/hi/LC_MESSAGES/volto.po +25 -0
  50. package/locales/hi.json +1 -1
  51. package/locales/hr/LC_MESSAGES/volto.po +25 -0
  52. package/locales/hr.json +1 -1
  53. package/locales/hu/LC_MESSAGES/volto.po +25 -0
  54. package/locales/hu.json +1 -1
  55. package/locales/hy/LC_MESSAGES/volto.po +25 -0
  56. package/locales/hy.json +1 -1
  57. package/locales/id/LC_MESSAGES/volto.po +25 -0
  58. package/locales/id.json +1 -1
  59. package/locales/it/LC_MESSAGES/volto.po +25 -0
  60. package/locales/it.json +1 -1
  61. package/locales/ja/LC_MESSAGES/volto.po +25 -0
  62. package/locales/ja.json +1 -1
  63. package/locales/ka/LC_MESSAGES/volto.po +25 -0
  64. package/locales/ka.json +1 -1
  65. package/locales/kn/LC_MESSAGES/volto.po +25 -0
  66. package/locales/kn.json +1 -1
  67. package/locales/ko/LC_MESSAGES/volto.po +25 -0
  68. package/locales/ko.json +1 -1
  69. package/locales/lt/LC_MESSAGES/volto.po +25 -0
  70. package/locales/lt.json +1 -1
  71. package/locales/lv/LC_MESSAGES/volto.po +25 -0
  72. package/locales/lv.json +1 -1
  73. package/locales/mi/LC_MESSAGES/volto.po +25 -0
  74. package/locales/mi.json +1 -1
  75. package/locales/mk/LC_MESSAGES/volto.po +25 -0
  76. package/locales/mk.json +1 -1
  77. package/locales/my/LC_MESSAGES/volto.po +25 -0
  78. package/locales/my.json +1 -1
  79. package/locales/nb_NO/LC_MESSAGES/volto.po +25 -0
  80. package/locales/nb_NO.json +1 -1
  81. package/locales/nl/LC_MESSAGES/volto.po +25 -0
  82. package/locales/nl.json +1 -1
  83. package/locales/nn/LC_MESSAGES/volto.po +25 -0
  84. package/locales/nn.json +1 -1
  85. package/locales/pl/LC_MESSAGES/volto.po +25 -0
  86. package/locales/pl.json +1 -1
  87. package/locales/pt/LC_MESSAGES/volto.po +25 -0
  88. package/locales/pt.json +1 -1
  89. package/locales/pt_BR/LC_MESSAGES/volto.po +25 -0
  90. package/locales/pt_BR.json +1 -1
  91. package/locales/rm/LC_MESSAGES/volto.po +25 -0
  92. package/locales/rm.json +1 -1
  93. package/locales/ro/LC_MESSAGES/volto.po +25 -0
  94. package/locales/ro.json +1 -1
  95. package/locales/ru/LC_MESSAGES/volto.po +25 -0
  96. package/locales/ru.json +1 -1
  97. package/locales/sk/LC_MESSAGES/volto.po +25 -0
  98. package/locales/sk.json +1 -1
  99. package/locales/sl/LC_MESSAGES/volto.po +25 -0
  100. package/locales/sl.json +1 -1
  101. package/locales/sm/LC_MESSAGES/volto.po +25 -0
  102. package/locales/sm.json +1 -1
  103. package/locales/sq/LC_MESSAGES/volto.po +25 -0
  104. package/locales/sq.json +1 -1
  105. package/locales/sr/LC_MESSAGES/volto.po +25 -0
  106. package/locales/sr.json +1 -1
  107. package/locales/sr@cyrl/LC_MESSAGES/volto.po +25 -0
  108. package/locales/sr@cyrl.json +1 -1
  109. package/locales/sr@latn/LC_MESSAGES/volto.po +25 -0
  110. package/locales/sr@latn.json +1 -1
  111. package/locales/sv/LC_MESSAGES/volto.po +25 -0
  112. package/locales/sv.json +1 -1
  113. package/locales/ta/LC_MESSAGES/volto.po +25 -0
  114. package/locales/ta.json +1 -1
  115. package/locales/te/LC_MESSAGES/volto.po +25 -0
  116. package/locales/te.json +1 -1
  117. package/locales/th/LC_MESSAGES/volto.po +25 -0
  118. package/locales/th.json +1 -1
  119. package/locales/to/LC_MESSAGES/volto.po +25 -0
  120. package/locales/to.json +1 -1
  121. package/locales/tr/LC_MESSAGES/volto.po +25 -0
  122. package/locales/tr.json +1 -1
  123. package/locales/uk/LC_MESSAGES/volto.po +25 -0
  124. package/locales/uk.json +1 -1
  125. package/locales/vi/LC_MESSAGES/volto.po +25 -0
  126. package/locales/vi.json +1 -1
  127. package/locales/volto.pot +26 -1
  128. package/locales/zh_CN/LC_MESSAGES/volto.po +25 -0
  129. package/locales/zh_CN.json +1 -1
  130. package/locales/zh_Hant/LC_MESSAGES/volto.po +25 -0
  131. package/locales/zh_Hant.json +1 -1
  132. package/locales/zh_Hant_HK/LC_MESSAGES/volto.po +25 -0
  133. package/locales/zh_Hant_HK.json +1 -1
  134. package/package.json +7 -7
  135. package/razzle.config.js +1 -0
  136. package/src/actions/users/users.js +2 -2
  137. package/src/components/manage/Blocks/Block/Order/Item.jsx +18 -10
  138. package/src/components/manage/Blocks/Block/Order/Item.test.jsx +90 -0
  139. package/src/components/manage/Controlpanels/AddonsControlpanel.jsx +7 -0
  140. package/src/components/manage/Controlpanels/DatabaseInformation.jsx +9 -0
  141. package/src/components/manage/Controlpanels/ModerateComments.jsx +8 -0
  142. package/src/components/manage/Controlpanels/Users/UserGroupMembershipControlPanel.test.jsx +3 -0
  143. package/src/components/manage/Widgets/DatetimeWidget.jsx +92 -58
  144. package/src/components/manage/Widgets/DatetimeWidget.test.jsx +55 -0
  145. package/src/components/manage/Widgets/FormFieldWrapper.jsx +7 -5
  146. package/src/components/manage/Widgets/TextWidget.jsx +4 -0
  147. package/src/components/manage/Widgets/UrlWidget.jsx +51 -6
  148. package/src/components/theme/Unauthorized/Unauthorized.jsx +30 -22
  149. package/src/components/theme/Unauthorized/Unauthorized.test.jsx +28 -1
  150. package/theme/themes/default/globals/site.variables +2 -2
  151. package/theme/themes/pastanaga/extras/main.less +15 -0
  152. package/theme/themes/pastanaga/globals/site.variables +0 -2
  153. package/types/components/manage/Blocks/Block/Order/Item.test.d.ts +1 -0
  154. package/webpack-plugins/webpack-less-plugin.js +1 -1
@@ -3,7 +3,7 @@
3
3
  * @module actions/users/users
4
4
  */
5
5
 
6
- import { stringify } from 'query-string';
6
+ import qs from 'query-string';
7
7
 
8
8
  import {
9
9
  CREATE_USER,
@@ -92,7 +92,7 @@ export function listUsers(options = {}) {
92
92
 
93
93
  let filterarg =
94
94
  groups_filter.length > 0
95
- ? stringify(
95
+ ? qs.stringify(
96
96
  { 'groups-filter': groups_filter },
97
97
  { arrayFormat: 'colon-list-separator' },
98
98
  )
@@ -44,6 +44,12 @@ export const Item = forwardRef(
44
44
  config.blocks.blocksConfig[data?.['@type']]?.icon ||
45
45
  config.blocks.blocksConfig.title?.icon;
46
46
 
47
+ const required =
48
+ typeof data?.required === 'boolean'
49
+ ? data.required
50
+ : includes(config.blocks.requiredBlocks, data?.['@type']);
51
+ const fixed = !!data?.fixed;
52
+
47
53
  return (
48
54
  <li
49
55
  className={classNames(
@@ -93,15 +99,17 @@ export const Item = forwardRef(
93
99
  ref={ref}
94
100
  style={style}
95
101
  >
96
- <button
97
- ref={ref}
98
- {...handleProps}
99
- className={classNames('action', 'drag')}
100
- tabIndex={0}
101
- data-cypress="draggable-handle"
102
- >
103
- <Icon name={dragSVG} size="16px" />
104
- </button>
102
+ {!fixed && (
103
+ <button
104
+ ref={ref}
105
+ {...handleProps}
106
+ className={classNames('action', 'drag')}
107
+ tabIndex={0}
108
+ data-cypress="draggable-handle"
109
+ >
110
+ <Icon name={dragSVG} size="16px" />
111
+ </button>
112
+ )}
105
113
  <span
106
114
  className={cx('text', {
107
115
  errored: errors && Object.keys(errors).length > 0,
@@ -118,7 +126,7 @@ export const Item = forwardRef(
118
126
  config.blocks.blocksConfig[data?.['@type']]?.title ||
119
127
  data?.title}
120
128
  </span>
121
- {!clone && onRemove && (
129
+ {!clone && onRemove && !required && (
122
130
  <button
123
131
  onClick={onRemove}
124
132
  className={classNames('action', 'delete')}
@@ -0,0 +1,90 @@
1
+ import React from 'react';
2
+ import configureStore from 'redux-mock-store';
3
+ import { Provider } from 'react-intl-redux';
4
+ import { render } from '@testing-library/react';
5
+ import config from '@plone/volto/registry';
6
+
7
+ import { Item } from './Item';
8
+
9
+ const mockStore = configureStore();
10
+
11
+ const defaultStoreState = {
12
+ intl: {
13
+ locale: 'en',
14
+ messages: {},
15
+ },
16
+ form: {
17
+ ui: {
18
+ selected: null,
19
+ hovered: null,
20
+ multiSelected: [],
21
+ gridSelected: null,
22
+ },
23
+ },
24
+ };
25
+
26
+ const renderItem = (data = {}) => {
27
+ const store = mockStore(defaultStoreState);
28
+
29
+ return render(
30
+ <Provider store={store}>
31
+ <Item
32
+ id="title-block-id"
33
+ data={{ '@type': 'title', ...data }}
34
+ depth={0}
35
+ indentationWidth={25}
36
+ onRemove={() => {}}
37
+ onSelectBlock={() => {}}
38
+ handleProps={{}}
39
+ />
40
+ </Provider>,
41
+ );
42
+ };
43
+
44
+ describe('Order Item', () => {
45
+ let requiredBlocks;
46
+
47
+ beforeEach(() => {
48
+ requiredBlocks = [...(config.blocks.requiredBlocks || [])];
49
+ config.blocks.requiredBlocks = [];
50
+ });
51
+
52
+ afterEach(() => {
53
+ config.blocks.requiredBlocks = requiredBlocks;
54
+ });
55
+
56
+ test('renders drag and delete actions for movable and removable blocks', () => {
57
+ const { container } = renderItem();
58
+
59
+ expect(container.querySelector('.action.drag')).not.toBeNull();
60
+ expect(container.querySelector('.action.delete')).not.toBeNull();
61
+ });
62
+
63
+ test('hides delete action for required blocks', () => {
64
+ const { container } = renderItem({ required: true });
65
+
66
+ expect(container.querySelector('.action.delete')).toBeNull();
67
+ });
68
+
69
+ test('hides delete action for block types configured as required', () => {
70
+ config.blocks.requiredBlocks = ['title'];
71
+
72
+ const { container } = renderItem();
73
+
74
+ expect(container.querySelector('.action.delete')).toBeNull();
75
+ });
76
+
77
+ test('allows explicit required=false to override required block types', () => {
78
+ config.blocks.requiredBlocks = ['title'];
79
+
80
+ const { container } = renderItem({ required: false });
81
+
82
+ expect(container.querySelector('.action.delete')).not.toBeNull();
83
+ });
84
+
85
+ test('hides drag action for fixed blocks', () => {
86
+ const { container } = renderItem({ fixed: true });
87
+
88
+ expect(container.querySelector('.action.drag')).toBeNull();
89
+ });
90
+ });
@@ -27,6 +27,7 @@ import Helmet from '@plone/volto/helpers/Helmet/Helmet';
27
27
  import Icon from '@plone/volto/components/theme/Icon/Icon';
28
28
  import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
29
29
  import Toast from '@plone/volto/components/manage/Toast/Toast';
30
+ import Error from '@plone/volto/components/theme/Error/Error';
30
31
  import circleBottomSVG from '@plone/volto/icons/circle-bottom.svg';
31
32
  import circleTopSVG from '@plone/volto/icons/circle-top.svg';
32
33
  import backSVG from '@plone/volto/icons/back.svg';
@@ -148,6 +149,7 @@ const AddonsControlpanel = (props) => {
148
149
  shallowEqual,
149
150
  );
150
151
  const loadingAddons = useSelector((state) => state.addons.loading);
152
+ const addonsError = useSelector((state) => state.addons.error);
151
153
 
152
154
  useEffect(() => {
153
155
  dispatch(listAddons());
@@ -245,6 +247,11 @@ const AddonsControlpanel = (props) => {
245
247
  setactiveIndex(newIndex);
246
248
  };
247
249
 
250
+ // Error handling for unauthorized access
251
+ if (addonsError) {
252
+ return <Error error={addonsError} />;
253
+ }
254
+
248
255
  return (
249
256
  <Container id="page-addons" className="controlpanel-addons">
250
257
  <Helmet title={intl.formatMessage(messages.addOns)} />
@@ -10,6 +10,7 @@ import Helmet from '@plone/volto/helpers/Helmet/Helmet';
10
10
  import { useClient } from '@plone/volto/hooks/client/useClient';
11
11
  import Icon from '@plone/volto/components/theme/Icon/Icon';
12
12
  import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
13
+ import Error from '@plone/volto/components/theme/Error/Error';
13
14
  import backSVG from '@plone/volto/icons/back.svg';
14
15
 
15
16
  const messages = defineMessages({
@@ -31,11 +32,19 @@ const DatabaseInformation = () => {
31
32
  const databaseInformation = useSelector(
32
33
  (state) => state.controlpanels.databaseinformation,
33
34
  );
35
+ const databaseError = useSelector(
36
+ (state) => state.controlpanels.database?.error,
37
+ );
34
38
 
35
39
  useEffect(() => {
36
40
  dispatch(getDatabaseInformation());
37
41
  }, [dispatch]);
38
42
 
43
+ // Error handling for unauthorized access
44
+ if (databaseError) {
45
+ return <Error error={databaseError} />;
46
+ }
47
+
39
48
  return databaseInformation ? (
40
49
  <Container id="database-page" className="controlpanel-database">
41
50
  <Helmet title={intl.formatMessage(messages.databaseInformation)} />
@@ -19,6 +19,7 @@ import { searchContent } from '@plone/volto/actions/search/search';
19
19
  import FormattedRelativeDate from '@plone/volto/components/theme/FormattedDate/FormattedRelativeDate';
20
20
  import Icon from '@plone/volto/components/theme/Icon/Icon';
21
21
  import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
22
+ import Error from '@plone/volto/components/theme/Error/Error';
22
23
  import { CommentEditModal } from '@plone/volto/components/theme/Comments';
23
24
 
24
25
  import backSVG from '@plone/volto/icons/back.svg';
@@ -65,6 +66,7 @@ class ModerateComments extends Component {
65
66
  loaded: PropTypes.bool,
66
67
  }).isRequired,
67
68
  pathname: PropTypes.string.isRequired,
69
+ searchError: PropTypes.object,
68
70
  };
69
71
 
70
72
  /**
@@ -186,6 +188,11 @@ class ModerateComments extends Component {
186
188
  * @returns {string} Markup for the component.
187
189
  */
188
190
  render() {
191
+ // Error handling for unauthorized access
192
+ if (this.props.searchError) {
193
+ return <Error error={this.props.searchError} />;
194
+ }
195
+
189
196
  return (
190
197
  <div id="page-moderate-comments">
191
198
  <CommentEditModal
@@ -297,6 +304,7 @@ export default compose(
297
304
  items: state.search.items,
298
305
  deleteRequest: state.comments.delete,
299
306
  pathname: props.location.pathname,
307
+ searchError: state.search.error,
300
308
  }),
301
309
  { deleteComment, searchContent },
302
310
  ),
@@ -14,6 +14,9 @@ vi.mock('../../Toolbar/Toolbar', () => ({
14
14
  describe('UserGroupMembershipControlPanel', () => {
15
15
  it('renders a user group membership control component', () => {
16
16
  const store = mockStore({
17
+ userSession: {
18
+ token: '1234',
19
+ },
17
20
  controlpanels: {
18
21
  controlpanel: {
19
22
  data: {
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useRef } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { defineMessages, useIntl } from 'react-intl';
4
4
  import loadable from '@loadable/component';
@@ -85,16 +85,22 @@ const DatetimeWidgetComponent = (props) => {
85
85
  noPastDates: propNoPastDates,
86
86
  isDisabled,
87
87
  formData,
88
+ required,
88
89
  } = props;
89
90
 
90
91
  const intl = useIntl();
91
92
  const lang = intl.locale;
92
93
 
94
+ // timeInputRef: for aria-required (rc-time-picker has no aria props)
95
+ const timeInputRef = useRef(null);
96
+
93
97
  const [focused, setFocused] = useState(false);
94
98
  const [isDefault, setIsDefault] = useState(false);
95
99
 
96
100
  const { SingleDatePicker } = reactDates;
97
101
 
102
+ const renderWidget = !(id === 'end' && formData?.open_end);
103
+
98
104
  useEffect(() => {
99
105
  const parsedDateTime = parseDateTime(
100
106
  toBackendLang(lang),
@@ -107,11 +113,6 @@ const DatetimeWidgetComponent = (props) => {
107
113
  );
108
114
  }, [value, lang, moment]);
109
115
 
110
- // If open_end is checked and this is the end field, don't render
111
- if (id === 'end' && formData?.open_end) {
112
- return null;
113
- }
114
-
115
116
  const getInternalValue = () => {
116
117
  return parseDateTime(toBackendLang(lang), value, undefined, moment.default);
117
118
  };
@@ -165,68 +166,101 @@ const DatetimeWidgetComponent = (props) => {
165
166
  const datetime = getInternalValue();
166
167
  const isDateOnly = getDateOnly();
167
168
 
169
+ // aria-required for the time input (rc-time-picker is lazy-loaded,
170
+ // so MutationObserver is needed to catch when it mounts its input)
171
+
172
+ // rc-time-picker does not have aria props, so we need to set aria-required
173
+ // manually on the input element when the required prop changes
174
+
175
+ useEffect(() => {
176
+ if (!renderWidget || isDateOnly) return;
177
+
178
+ function applyTimeAria() {
179
+ const input = timeInputRef.current?.querySelector('input');
180
+ if (!input) return;
181
+ if (required) input.setAttribute('aria-required', 'true');
182
+ else input.removeAttribute('aria-required');
183
+ }
184
+
185
+ applyTimeAria();
186
+
187
+ const observer = new MutationObserver(applyTimeAria);
188
+ if (timeInputRef.current) {
189
+ observer.observe(timeInputRef.current, {
190
+ childList: true,
191
+ subtree: true,
192
+ });
193
+ }
194
+
195
+ return () => observer.disconnect();
196
+ }, [required, isDateOnly, renderWidget]);
197
+
168
198
  return (
169
199
  <FormFieldWrapper {...props}>
170
- <div className="date-time-widget-wrapper">
171
- <div
172
- className={cx('ui input date-input', {
173
- 'default-date': isDefault,
174
- })}
175
- >
176
- <SingleDatePicker
177
- date={datetime}
178
- disabled={isDisabled}
179
- onDateChange={onDateChange}
180
- focused={focused}
181
- numberOfMonths={1}
182
- {...(noPastDates ? {} : { isOutsideRange: () => false })}
183
- onFocusChange={onFocusChange}
184
- noBorder
185
- displayFormat={moment.default
186
- .localeData(toBackendLang(lang))
187
- .longDateFormat('L')}
188
- navPrev={<PrevIcon />}
189
- navNext={<NextIcon />}
190
- id={`${id}-date`}
191
- placeholder={intl.formatMessage(messages.date)}
192
- />
193
- </div>
194
- {!isDateOnly && (
200
+ {renderWidget && (
201
+ <div className="date-time-widget-wrapper">
195
202
  <div
196
- className={cx('ui input time-input', {
203
+ className={cx('ui input date-input', {
197
204
  'default-date': isDefault,
198
205
  })}
199
206
  >
200
- <TimePicker
207
+ <SingleDatePicker
208
+ date={datetime}
201
209
  disabled={isDisabled}
202
- defaultValue={datetime}
203
- value={datetime}
204
- onChange={onTimeChange}
205
- allowEmpty={false}
206
- showSecond={false}
207
- use12Hours={lang === 'en'}
208
- id={`${id}-time`}
209
- format={moment.default
210
+ onDateChange={onDateChange}
211
+ focused={focused}
212
+ numberOfMonths={1}
213
+ {...(noPastDates ? {} : { isOutsideRange: () => false })}
214
+ onFocusChange={onFocusChange}
215
+ noBorder
216
+ required={required}
217
+ displayFormat={moment.default
210
218
  .localeData(toBackendLang(lang))
211
- .longDateFormat('LT')}
212
- placeholder={intl.formatMessage(messages.time)}
213
- focusOnOpen
214
- placement="bottomRight"
219
+ .longDateFormat('L')}
220
+ navPrev={<PrevIcon />}
221
+ navNext={<NextIcon />}
222
+ id={`${id}-date`}
223
+ placeholder={intl.formatMessage(messages.date)}
215
224
  />
216
225
  </div>
217
- )}
218
- {resettable && (
219
- <button
220
- type="button"
221
- disabled={isDisabled || !datetime}
222
- onClick={onResetDates}
223
- className="item ui noborder button"
224
- aria-label={intl.formatMessage(messages.clearDateTime)}
225
- >
226
- <Icon name={clearSVG} size="24px" className="close" />
227
- </button>
228
- )}
229
- </div>
226
+ {!isDateOnly && (
227
+ <div
228
+ ref={timeInputRef}
229
+ className={cx('ui input time-input', {
230
+ 'default-date': isDefault,
231
+ })}
232
+ >
233
+ <TimePicker
234
+ disabled={isDisabled}
235
+ defaultValue={datetime}
236
+ value={datetime}
237
+ onChange={onTimeChange}
238
+ allowEmpty={false}
239
+ showSecond={false}
240
+ use12Hours={lang === 'en'}
241
+ id={`${id}-time`}
242
+ format={moment.default
243
+ .localeData(toBackendLang(lang))
244
+ .longDateFormat('LT')}
245
+ placeholder={intl.formatMessage(messages.time)}
246
+ focusOnOpen
247
+ placement="bottomRight"
248
+ />
249
+ </div>
250
+ )}
251
+ {resettable && (
252
+ <button
253
+ type="button"
254
+ disabled={isDisabled || !datetime}
255
+ onClick={onResetDates}
256
+ className="item ui noborder button"
257
+ aria-label={intl.formatMessage(messages.clearDateTime)}
258
+ >
259
+ <Icon name={clearSVG} size="24px" className="close" />
260
+ </button>
261
+ )}
262
+ </div>
263
+ )}
230
264
  </FormFieldWrapper>
231
265
  );
232
266
  };
@@ -71,3 +71,58 @@ test('datetime widget converts UTC date and adapts to local datetime', async ()
71
71
  await waitFor(() => screen.getByPlaceholderText('Time'));
72
72
  expect(container).toMatchSnapshot();
73
73
  });
74
+
75
+ test('applies aria-required attribute to the date input when required prop is true', async () => {
76
+ const store = mockStore({
77
+ intl: {
78
+ locale: 'en',
79
+ messages: {},
80
+ },
81
+ });
82
+
83
+ const { container } = render(
84
+ <Provider store={store}>
85
+ <DatetimeWidget
86
+ id="required-field"
87
+ title="Required Field"
88
+ onChange={() => {}}
89
+ required={true}
90
+ />
91
+ </Provider>,
92
+ );
93
+
94
+ await waitFor(() => screen.getByPlaceholderText('Date'));
95
+
96
+ const dateInput = container.querySelector('.date-input input');
97
+
98
+ expect(dateInput).toHaveAttribute('required');
99
+ });
100
+
101
+ test('applies aria-required attribute to the time input when required prop is true', async () => {
102
+ const store = mockStore({
103
+ intl: {
104
+ locale: 'en',
105
+ messages: {},
106
+ },
107
+ });
108
+
109
+ const { container } = render(
110
+ <Provider store={store}>
111
+ <DatetimeWidget
112
+ id="required-field"
113
+ title="Required Field"
114
+ onChange={() => {}}
115
+ required={true}
116
+ />
117
+ </Provider>,
118
+ );
119
+
120
+ // Wait for the lazy-loaded TimePicker to be mounted in the DOM
121
+ await waitFor(() => screen.getByPlaceholderText('Time'));
122
+
123
+ // The rc-time-picker doesn't support aria-required natively,
124
+ // so we verify if our MutationObserver/useEffect successfully injected it.
125
+ const timeInput = container.querySelector('.time-input input');
126
+
127
+ expect(timeInput).toHaveAttribute('aria-required', 'true');
128
+ });
@@ -105,11 +105,13 @@ class FormFieldWrapper extends Component {
105
105
  <>
106
106
  {this.props.children}
107
107
 
108
- {map(error, (message) => (
109
- <Label key={message} basic color="red" className="form-error-label">
110
- {message}
111
- </Label>
112
- ))}
108
+ <div aria-live="polite" aria-atomic="true">
109
+ {map(error, (message) => (
110
+ <Label key={message} basic color="red" className="form-error-label">
111
+ {message}
112
+ </Label>
113
+ ))}
114
+ </div>
113
115
  </>
114
116
  );
115
117
 
@@ -19,6 +19,8 @@ const TextWidget = (props) => {
19
19
  placeholder,
20
20
  isDisabled,
21
21
  focus,
22
+ required,
23
+ error,
22
24
  } = props;
23
25
 
24
26
  const ref = useRef();
@@ -49,6 +51,8 @@ const TextWidget = (props) => {
49
51
  onClick={() => onClick()}
50
52
  minLength={minLength || null}
51
53
  maxLength={maxLength || null}
54
+ aria-required={required ? 'true' : undefined}
55
+ aria-invalid={error?.length > 0 ? 'true' : undefined}
52
56
  />
53
57
  {icon && iconAction && (
54
58
  <button className={`field-${id}-action-button`} onClick={iconAction}>
@@ -14,10 +14,30 @@ import {
14
14
  flattenToAppURL,
15
15
  URLUtils,
16
16
  } from '@plone/volto/helpers/Url/Url';
17
+ import { defineMessages, useIntl } from 'react-intl';
17
18
  import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser';
18
19
  import clearSVG from '@plone/volto/icons/clear.svg';
19
20
  import navTreeSVG from '@plone/volto/icons/nav.svg';
20
21
 
22
+ const messages = defineMessages({
23
+ urlMissing: {
24
+ id: 'URL is missing',
25
+ defaultMessage: 'URL is missing',
26
+ },
27
+ urlInvalid: {
28
+ id: 'URL is invalid',
29
+ defaultMessage: 'URL is invalid',
30
+ },
31
+ clearUrl: {
32
+ id: 'Clear URL',
33
+ defaultMessage: 'Clear URL',
34
+ },
35
+ openUrlBrowser: {
36
+ id: 'Open URL browser',
37
+ defaultMessage: 'Open URL browser',
38
+ },
39
+ });
40
+
21
41
  /** Widget to edit urls
22
42
  *
23
43
  * This is the default widget used for the `remoteUrl` field. You can also use
@@ -40,9 +60,10 @@ export const UrlWidget = (props) => {
40
60
  maxLength,
41
61
  placeholder,
42
62
  isDisabled,
63
+ required,
43
64
  } = props;
44
65
  const inputId = `field-${id}`;
45
-
66
+ const intl = useIntl();
46
67
  const [value, setValue] = useState(flattenToAppURL(props.value));
47
68
  const [isInvalid, setIsInvalid] = useState(false);
48
69
  /**
@@ -54,6 +75,7 @@ export const UrlWidget = (props) => {
54
75
  const clear = () => {
55
76
  setValue('');
56
77
  onChange(id, undefined);
78
+ setIsInvalid(false);
57
79
  };
58
80
 
59
81
  const onChangeValue = (_value) => {
@@ -83,6 +105,14 @@ export const UrlWidget = (props) => {
83
105
  onChange(id, newValue === '' ? undefined : newValue);
84
106
  };
85
107
 
108
+ // A11y: if the field is required and the user leaves it empty, we mark it as missing
109
+ const handleBlur = ({ target }) => {
110
+ if (required && (!target.value || target.value === '')) {
111
+ setIsInvalid(true);
112
+ }
113
+ onBlur(id, target.value === '' ? undefined : target.value);
114
+ };
115
+
86
116
  return (
87
117
  <FormFieldWrapper {...props} className="url wide">
88
118
  <div className="wrapper">
@@ -90,24 +120,38 @@ export const UrlWidget = (props) => {
90
120
  id={inputId}
91
121
  name={id}
92
122
  type="url"
123
+ required={required}
124
+ aria-required={required}
125
+ aria-invalid={isInvalid}
126
+ aria-errormessage={isInvalid ? `${inputId}-error` : undefined}
93
127
  value={value || ''}
94
128
  disabled={isDisabled}
95
129
  placeholder={placeholder}
96
130
  onChange={({ target }) => onChangeValue(target.value)}
97
- onBlur={({ target }) =>
98
- onBlur(id, target.value === '' ? undefined : target.value)
99
- }
131
+ onBlur={handleBlur}
100
132
  onClick={() => onClick()}
101
133
  minLength={minLength || null}
102
134
  maxLength={maxLength || null}
103
135
  error={isInvalid}
104
136
  />
137
+ {isInvalid && (
138
+ <span
139
+ id={`${inputId}-error`}
140
+ role="alert"
141
+ className="visually-hidden-volto"
142
+ >
143
+ {value?.length > 0
144
+ ? intl.formatMessage(messages.urlInvalid)
145
+ : intl.formatMessage(messages.urlMissing)}
146
+ </span>
147
+ )}
105
148
  {value?.length > 0 ? (
106
149
  <Button.Group>
107
150
  <Button
151
+ type="button"
108
152
  basic
109
153
  className="cancel"
110
- aria-label="clearUrlBrowser"
154
+ aria-label={intl.formatMessage(messages.clearUrl)}
111
155
  onClick={(e) => {
112
156
  e.preventDefault();
113
157
  e.stopPropagation();
@@ -120,9 +164,10 @@ export const UrlWidget = (props) => {
120
164
  ) : (
121
165
  <Button.Group>
122
166
  <Button
167
+ type="button"
123
168
  basic
124
169
  icon
125
- aria-label="openUrlBrowser"
170
+ aria-label={intl.formatMessage(messages.openUrlBrowser)}
126
171
  onClick={(e) => {
127
172
  e.preventDefault();
128
173
  e.stopPropagation();