@plone/volto 18.33.0 → 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 (160) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +0 -1
  3. package/locales/af/LC_MESSAGES/volto.po +27 -2
  4. package/locales/af.json +1 -1
  5. package/locales/ar/LC_MESSAGES/volto.po +27 -2
  6. package/locales/ar.json +1 -1
  7. package/locales/bg/LC_MESSAGES/volto.po +27 -2
  8. package/locales/bg.json +1 -1
  9. package/locales/bn/LC_MESSAGES/volto.po +27 -2
  10. package/locales/bn.json +1 -1
  11. package/locales/ca/LC_MESSAGES/volto.po +26 -1
  12. package/locales/ca.json +1 -1
  13. package/locales/cs/LC_MESSAGES/volto.po +27 -2
  14. package/locales/cs.json +1 -1
  15. package/locales/cy/LC_MESSAGES/volto.po +27 -2
  16. package/locales/cy.json +1 -1
  17. package/locales/da/LC_MESSAGES/volto.po +27 -2
  18. package/locales/da.json +1 -1
  19. package/locales/de/LC_MESSAGES/volto.po +26 -1
  20. package/locales/de.json +1 -1
  21. package/locales/el/LC_MESSAGES/volto.po +27 -2
  22. package/locales/el.json +1 -1
  23. package/locales/en/LC_MESSAGES/volto.po +27 -2
  24. package/locales/en.json +1 -1
  25. package/locales/en_AU/LC_MESSAGES/volto.po +27 -2
  26. package/locales/en_AU.json +1 -1
  27. package/locales/en_GB/LC_MESSAGES/volto.po +27 -2
  28. package/locales/en_GB.json +1 -1
  29. package/locales/eo/LC_MESSAGES/volto.po +27 -2
  30. package/locales/eo.json +1 -1
  31. package/locales/es/LC_MESSAGES/volto.po +43 -18
  32. package/locales/es.json +1 -1
  33. package/locales/et/LC_MESSAGES/volto.po +27 -2
  34. package/locales/et.json +1 -1
  35. package/locales/eu/LC_MESSAGES/volto.po +26 -1
  36. package/locales/eu.json +1 -1
  37. package/locales/fa/LC_MESSAGES/volto.po +27 -2
  38. package/locales/fa.json +1 -1
  39. package/locales/fi/LC_MESSAGES/volto.po +26 -1
  40. package/locales/fi.json +1 -1
  41. package/locales/fr/LC_MESSAGES/volto.po +26 -1
  42. package/locales/fr.json +1 -1
  43. package/locales/fu/LC_MESSAGES/volto.po +27 -2
  44. package/locales/fu.json +1 -1
  45. package/locales/gl/LC_MESSAGES/volto.po +31 -6
  46. package/locales/gl.json +1 -1
  47. package/locales/he/LC_MESSAGES/volto.po +27 -2
  48. package/locales/he.json +1 -1
  49. package/locales/hi/LC_MESSAGES/volto.po +26 -1
  50. package/locales/hi.json +1 -1
  51. package/locales/hr/LC_MESSAGES/volto.po +27 -2
  52. package/locales/hr.json +1 -1
  53. package/locales/hu/LC_MESSAGES/volto.po +27 -2
  54. package/locales/hu.json +1 -1
  55. package/locales/hy/LC_MESSAGES/volto.po +27 -2
  56. package/locales/hy.json +1 -1
  57. package/locales/id/LC_MESSAGES/volto.po +27 -2
  58. package/locales/id.json +1 -1
  59. package/locales/it/LC_MESSAGES/volto.po +26 -1
  60. package/locales/it.json +1 -1
  61. package/locales/ja/LC_MESSAGES/volto.po +26 -1
  62. package/locales/ja.json +1 -1
  63. package/locales/ka/LC_MESSAGES/volto.po +27 -2
  64. package/locales/ka.json +1 -1
  65. package/locales/kn/LC_MESSAGES/volto.po +27 -2
  66. package/locales/kn.json +1 -1
  67. package/locales/ko/LC_MESSAGES/volto.po +27 -2
  68. package/locales/ko.json +1 -1
  69. package/locales/lt/LC_MESSAGES/volto.po +27 -2
  70. package/locales/lt.json +1 -1
  71. package/locales/lv/LC_MESSAGES/volto.po +27 -2
  72. package/locales/lv.json +1 -1
  73. package/locales/mi/LC_MESSAGES/volto.po +27 -2
  74. package/locales/mi.json +1 -1
  75. package/locales/mk/LC_MESSAGES/volto.po +27 -2
  76. package/locales/mk.json +1 -1
  77. package/locales/my/LC_MESSAGES/volto.po +27 -2
  78. package/locales/my.json +1 -1
  79. package/locales/nb_NO/LC_MESSAGES/volto.po +27 -2
  80. package/locales/nb_NO.json +1 -1
  81. package/locales/nl/LC_MESSAGES/volto.po +26 -1
  82. package/locales/nl.json +1 -1
  83. package/locales/nn/LC_MESSAGES/volto.po +27 -2
  84. package/locales/nn.json +1 -1
  85. package/locales/pl/LC_MESSAGES/volto.po +27 -2
  86. package/locales/pl.json +1 -1
  87. package/locales/pt/LC_MESSAGES/volto.po +26 -1
  88. package/locales/pt.json +1 -1
  89. package/locales/pt_BR/LC_MESSAGES/volto.po +26 -1
  90. package/locales/pt_BR.json +1 -1
  91. package/locales/rm/LC_MESSAGES/volto.po +27 -2
  92. package/locales/rm.json +1 -1
  93. package/locales/ro/LC_MESSAGES/volto.po +26 -1
  94. package/locales/ro.json +1 -1
  95. package/locales/ru/LC_MESSAGES/volto.po +26 -1
  96. package/locales/ru.json +1 -1
  97. package/locales/sk/LC_MESSAGES/volto.po +27 -2
  98. package/locales/sk.json +1 -1
  99. package/locales/sl/LC_MESSAGES/volto.po +27 -2
  100. package/locales/sl.json +1 -1
  101. package/locales/sm/LC_MESSAGES/volto.po +27 -2
  102. package/locales/sm.json +1 -1
  103. package/locales/sq/LC_MESSAGES/volto.po +27 -2
  104. package/locales/sq.json +1 -1
  105. package/locales/sr/LC_MESSAGES/volto.po +27 -2
  106. package/locales/sr.json +1 -1
  107. package/locales/sr@cyrl/LC_MESSAGES/volto.po +27 -2
  108. package/locales/sr@cyrl.json +1 -1
  109. package/locales/sr@latn/LC_MESSAGES/volto.po +27 -2
  110. package/locales/sr@latn.json +1 -1
  111. package/locales/sv/LC_MESSAGES/volto.po +26 -1
  112. package/locales/sv.json +1 -1
  113. package/locales/ta/LC_MESSAGES/volto.po +26 -1
  114. package/locales/ta.json +1 -1
  115. package/locales/te/LC_MESSAGES/volto.po +27 -2
  116. package/locales/te.json +1 -1
  117. package/locales/th/LC_MESSAGES/volto.po +27 -2
  118. package/locales/th.json +1 -1
  119. package/locales/to/LC_MESSAGES/volto.po +27 -2
  120. package/locales/to.json +1 -1
  121. package/locales/tr/LC_MESSAGES/volto.po +27 -2
  122. package/locales/tr.json +1 -1
  123. package/locales/uk/LC_MESSAGES/volto.po +27 -2
  124. package/locales/uk.json +1 -1
  125. package/locales/vi/LC_MESSAGES/volto.po +27 -2
  126. package/locales/vi.json +1 -1
  127. package/locales/volto.pot +27 -2
  128. package/locales/zh_CN/LC_MESSAGES/volto.po +26 -1
  129. package/locales/zh_CN.json +1 -1
  130. package/locales/zh_Hant/LC_MESSAGES/volto.po +27 -2
  131. package/locales/zh_Hant.json +1 -1
  132. package/locales/zh_Hant_HK/LC_MESSAGES/volto.po +27 -2
  133. package/locales/zh_Hant_HK.json +1 -1
  134. package/package.json +9 -9
  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/Sharing/Sharing.jsx +11 -3
  144. package/src/components/manage/Widgets/DatetimeWidget.jsx +92 -58
  145. package/src/components/manage/Widgets/DatetimeWidget.test.jsx +55 -0
  146. package/src/components/manage/Widgets/FormFieldWrapper.jsx +7 -5
  147. package/src/components/manage/Widgets/RegistryImageWidget.jsx +1 -1
  148. package/src/components/manage/Widgets/TextWidget.jsx +4 -0
  149. package/src/components/manage/Widgets/UrlWidget.jsx +51 -6
  150. package/src/components/theme/AlternateHrefLangs/AlternateHrefLangs.jsx +1 -0
  151. package/src/components/theme/AlternateHrefLangs/AlternateHrefLangs.test.jsx +30 -0
  152. package/src/components/theme/Unauthorized/Unauthorized.jsx +30 -22
  153. package/src/components/theme/Unauthorized/Unauthorized.test.jsx +28 -1
  154. package/theme/themes/default/globals/site.variables +2 -2
  155. package/theme/themes/pastanaga/collections/form.overrides +1 -1
  156. package/theme/themes/pastanaga/elements/segment.variables +1 -1
  157. package/theme/themes/pastanaga/extras/main.less +15 -0
  158. package/theme/themes/pastanaga/globals/site.variables +0 -2
  159. package/types/components/manage/Blocks/Block/Order/Item.test.d.ts +1 -0
  160. package/webpack-plugins/webpack-less-plugin.js +1 -1
package/razzle.config.js CHANGED
@@ -310,6 +310,7 @@ const defaultModify = ({
310
310
  'load-volto-addons': addonsLoaderPath,
311
311
  ...registry.getResolveAliases(),
312
312
  '@plone/volto': `${registry.voltoPath}/src`,
313
+ '@plone/volto-slate': `${registry.voltoPath}/../volto-slate/src`,
313
314
  // to be able to reference path uncustomized by webpack
314
315
  '@plone/volto-original': `${registry.voltoPath}/src`,
315
316
  // be able to reference current package from customized package
@@ -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: {
@@ -473,13 +473,21 @@ class SharingComponent extends Component {
473
473
  <p className="help">
474
474
  <FormattedMessage
475
475
  id="By default, permissions from the container of this item are inherited. If you disable this, only the explicitly defined sharing permissions will be valid. In the overview, the symbol {inherited} indicates an inherited value. Similarly, the symbol {global} indicates a global role, which is managed by the site administrator."
476
- defaultMessage="By default, permissions from the container of this item are inherited. If you disable this, only the explicitly defined sharing permissions will be valid. In the overview, the symbol {inherited} indicates an inherited value. Similarly, the symbol {global} indicates a global role, which is managed by the site administrator."
476
+ defaultMessage="By default, permissions from the container of this item are inherited. If you disable this, only the explicitly defined sharing permissions will be valid. In the overview, inherited values are explicitly labeled as 'Inherited value' and receive a green check mark {inherited}. Similarly, roles managed by the site administrator are labeled as 'Global role' and receive a blue check mark {global}."
477
477
  values={{
478
478
  inherited: (
479
- <IconOld name="check circle outline" color="green" />
479
+ <IconOld
480
+ aria-hidden="true"
481
+ name="check circle outline"
482
+ color="green"
483
+ />
480
484
  ),
481
485
  global: (
482
- <IconOld name="check circle outline" color="blue" />
486
+ <IconOld
487
+ aria-hidden="true"
488
+ name="check circle outline"
489
+ color="blue"
490
+ />
483
491
  ),
484
492
  }}
485
493
  />
@@ -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
 
@@ -99,7 +99,7 @@ const RegistryImageWidget = (props) => {
99
99
 
100
100
  readAsDataURL(file).then((data) => {
101
101
  const fields = data.match(/^data:(.*);(.*),(.*)$/);
102
- onChange(id, `filenameb64:${btoa(file.name)};datab64:${fields[3]}}`);
102
+ onChange(id, `filenameb64:${btoa(file.name)};datab64:${fields[3]}`);
103
103
  });
104
104
 
105
105
  let reader = new FileReader();
@@ -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}>