@plone/volto 18.33.1 → 18.35.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 (173) hide show
  1. package/.release-it.json +3 -0
  2. package/CHANGELOG.md +52 -0
  3. package/README.md +0 -1
  4. package/locales/af/LC_MESSAGES/volto.po +55 -0
  5. package/locales/af.json +1 -1
  6. package/locales/ar/LC_MESSAGES/volto.po +55 -0
  7. package/locales/ar.json +1 -1
  8. package/locales/bg/LC_MESSAGES/volto.po +55 -0
  9. package/locales/bg.json +1 -1
  10. package/locales/bn/LC_MESSAGES/volto.po +55 -0
  11. package/locales/bn.json +1 -1
  12. package/locales/ca/LC_MESSAGES/volto.po +57 -2
  13. package/locales/ca.json +1 -1
  14. package/locales/cs/LC_MESSAGES/volto.po +55 -0
  15. package/locales/cs.json +1 -1
  16. package/locales/cy/LC_MESSAGES/volto.po +55 -0
  17. package/locales/cy.json +1 -1
  18. package/locales/da/LC_MESSAGES/volto.po +55 -0
  19. package/locales/da.json +1 -1
  20. package/locales/de/LC_MESSAGES/volto.po +59 -4
  21. package/locales/de.json +1 -1
  22. package/locales/el/LC_MESSAGES/volto.po +55 -0
  23. package/locales/el.json +1 -1
  24. package/locales/en/LC_MESSAGES/volto.po +55 -0
  25. package/locales/en.json +1 -1
  26. package/locales/en_AU/LC_MESSAGES/volto.po +55 -0
  27. package/locales/en_AU.json +1 -1
  28. package/locales/en_GB/LC_MESSAGES/volto.po +55 -0
  29. package/locales/en_GB.json +1 -1
  30. package/locales/eo/LC_MESSAGES/volto.po +55 -0
  31. package/locales/eo.json +1 -1
  32. package/locales/es/LC_MESSAGES/volto.po +73 -18
  33. package/locales/es.json +1 -1
  34. package/locales/et/LC_MESSAGES/volto.po +55 -0
  35. package/locales/et.json +1 -1
  36. package/locales/eu/LC_MESSAGES/volto.po +58 -3
  37. package/locales/eu.json +1 -1
  38. package/locales/fa/LC_MESSAGES/volto.po +55 -0
  39. package/locales/fa.json +1 -1
  40. package/locales/fi/LC_MESSAGES/volto.po +55 -0
  41. package/locales/fi.json +1 -1
  42. package/locales/fr/LC_MESSAGES/volto.po +56 -1
  43. package/locales/fr.json +1 -1
  44. package/locales/fu/LC_MESSAGES/volto.po +55 -0
  45. package/locales/fu.json +1 -1
  46. package/locales/gl/LC_MESSAGES/volto.po +60 -5
  47. package/locales/gl.json +1 -1
  48. package/locales/he/LC_MESSAGES/volto.po +55 -0
  49. package/locales/he.json +1 -1
  50. package/locales/hi/LC_MESSAGES/volto.po +58 -3
  51. package/locales/hi.json +1 -1
  52. package/locales/hr/LC_MESSAGES/volto.po +55 -0
  53. package/locales/hr.json +1 -1
  54. package/locales/hu/LC_MESSAGES/volto.po +55 -0
  55. package/locales/hu.json +1 -1
  56. package/locales/hy/LC_MESSAGES/volto.po +55 -0
  57. package/locales/hy.json +1 -1
  58. package/locales/id/LC_MESSAGES/volto.po +55 -0
  59. package/locales/id.json +1 -1
  60. package/locales/it/LC_MESSAGES/volto.po +56 -1
  61. package/locales/it.json +1 -1
  62. package/locales/ja/LC_MESSAGES/volto.po +55 -0
  63. package/locales/ja.json +1 -1
  64. package/locales/ka/LC_MESSAGES/volto.po +55 -0
  65. package/locales/ka.json +1 -1
  66. package/locales/kn/LC_MESSAGES/volto.po +55 -0
  67. package/locales/kn.json +1 -1
  68. package/locales/ko/LC_MESSAGES/volto.po +55 -0
  69. package/locales/ko.json +1 -1
  70. package/locales/lt/LC_MESSAGES/volto.po +55 -0
  71. package/locales/lt.json +1 -1
  72. package/locales/lv/LC_MESSAGES/volto.po +55 -0
  73. package/locales/lv.json +1 -1
  74. package/locales/mi/LC_MESSAGES/volto.po +55 -0
  75. package/locales/mi.json +1 -1
  76. package/locales/mk/LC_MESSAGES/volto.po +55 -0
  77. package/locales/mk.json +1 -1
  78. package/locales/my/LC_MESSAGES/volto.po +55 -0
  79. package/locales/my.json +1 -1
  80. package/locales/nb_NO/LC_MESSAGES/volto.po +55 -0
  81. package/locales/nb_NO.json +1 -1
  82. package/locales/nl/LC_MESSAGES/volto.po +56 -1
  83. package/locales/nl.json +1 -1
  84. package/locales/nn/LC_MESSAGES/volto.po +55 -0
  85. package/locales/nn.json +1 -1
  86. package/locales/pl/LC_MESSAGES/volto.po +55 -0
  87. package/locales/pl.json +1 -1
  88. package/locales/pt/LC_MESSAGES/volto.po +55 -0
  89. package/locales/pt.json +1 -1
  90. package/locales/pt_BR/LC_MESSAGES/volto.po +69 -14
  91. package/locales/pt_BR.json +1 -1
  92. package/locales/rm/LC_MESSAGES/volto.po +55 -0
  93. package/locales/rm.json +1 -1
  94. package/locales/ro/LC_MESSAGES/volto.po +56 -1
  95. package/locales/ro.json +1 -1
  96. package/locales/ru/LC_MESSAGES/volto.po +56 -1
  97. package/locales/ru.json +1 -1
  98. package/locales/sk/LC_MESSAGES/volto.po +55 -0
  99. package/locales/sk.json +1 -1
  100. package/locales/sl/LC_MESSAGES/volto.po +55 -0
  101. package/locales/sl.json +1 -1
  102. package/locales/sm/LC_MESSAGES/volto.po +55 -0
  103. package/locales/sm.json +1 -1
  104. package/locales/sq/LC_MESSAGES/volto.po +55 -0
  105. package/locales/sq.json +1 -1
  106. package/locales/sr/LC_MESSAGES/volto.po +55 -0
  107. package/locales/sr.json +1 -1
  108. package/locales/sr@cyrl/LC_MESSAGES/volto.po +55 -0
  109. package/locales/sr@cyrl.json +1 -1
  110. package/locales/sr@latn/LC_MESSAGES/volto.po +55 -0
  111. package/locales/sr@latn.json +1 -1
  112. package/locales/sv/LC_MESSAGES/volto.po +55 -0
  113. package/locales/sv.json +1 -1
  114. package/locales/ta/LC_MESSAGES/volto.po +56 -1
  115. package/locales/ta.json +1 -1
  116. package/locales/te/LC_MESSAGES/volto.po +55 -0
  117. package/locales/te.json +1 -1
  118. package/locales/th/LC_MESSAGES/volto.po +55 -0
  119. package/locales/th.json +1 -1
  120. package/locales/to/LC_MESSAGES/volto.po +55 -0
  121. package/locales/to.json +1 -1
  122. package/locales/tr/LC_MESSAGES/volto.po +55 -0
  123. package/locales/tr.json +1 -1
  124. package/locales/uk/LC_MESSAGES/volto.po +55 -0
  125. package/locales/uk.json +1 -1
  126. package/locales/vi/LC_MESSAGES/volto.po +55 -0
  127. package/locales/vi.json +1 -1
  128. package/locales/volto.pot +56 -1
  129. package/locales/zh_CN/LC_MESSAGES/volto.po +55 -0
  130. package/locales/zh_CN.json +1 -1
  131. package/locales/zh_Hant/LC_MESSAGES/volto.po +55 -0
  132. package/locales/zh_Hant.json +1 -1
  133. package/locales/zh_Hant_HK/LC_MESSAGES/volto.po +55 -0
  134. package/locales/zh_Hant_HK.json +1 -1
  135. package/news/7308.fix +1 -0
  136. package/news/8084.fix +1 -0
  137. package/package.json +19 -19
  138. package/razzle.config.js +1 -0
  139. package/src/actions/users/users.js +2 -2
  140. package/src/components/manage/Blocks/Block/Order/Item.jsx +18 -10
  141. package/src/components/manage/Blocks/Block/Order/Item.test.jsx +90 -0
  142. package/src/components/manage/Controlpanels/AddonsControlpanel.jsx +7 -0
  143. package/src/components/manage/Controlpanels/ContentTypes.jsx +9 -2
  144. package/src/components/manage/Controlpanels/DatabaseInformation.jsx +9 -0
  145. package/src/components/manage/Controlpanels/ModerateComments.jsx +8 -0
  146. package/src/components/manage/Controlpanels/Users/UserGroupMembershipControlPanel.test.jsx +3 -0
  147. package/src/components/manage/Controlpanels/Users/UsersControlpanel.jsx +58 -5
  148. package/src/components/manage/Controlpanels/Users/UsersControlpanel.ssr.test.jsx +624 -0
  149. package/src/components/manage/Controlpanels/Users/UsersControlpanel.test.jsx +8 -0
  150. package/src/components/manage/Form/Form.jsx +6 -1
  151. package/src/components/manage/Form/ModalForm.jsx +165 -87
  152. package/src/components/manage/Sidebar/Sidebar.jsx +1 -0
  153. package/src/components/manage/Toast/Toast.jsx +35 -1
  154. package/src/components/manage/Toast/Toast.test.jsx +8 -5
  155. package/src/components/manage/Widgets/DatetimeWidget.jsx +92 -58
  156. package/src/components/manage/Widgets/DatetimeWidget.test.jsx +55 -0
  157. package/src/components/manage/Widgets/FormFieldWrapper.jsx +7 -5
  158. package/src/components/manage/Widgets/TextWidget.jsx +4 -0
  159. package/src/components/manage/Widgets/UrlWidget.jsx +51 -6
  160. package/src/components/theme/Search/Search.jsx +24 -1
  161. package/src/components/theme/Unauthorized/Unauthorized.jsx +30 -22
  162. package/src/components/theme/Unauthorized/Unauthorized.test.jsx +28 -1
  163. package/src/helpers/FormValidation/validators.ts +15 -2
  164. package/theme/themes/default/globals/site.variables +2 -2
  165. package/theme/themes/pastanaga/collections/form.overrides +21 -0
  166. package/theme/themes/pastanaga/elements/button.overrides +30 -3
  167. package/theme/themes/pastanaga/extras/main.less +16 -0
  168. package/theme/themes/pastanaga/globals/site.variables +0 -2
  169. package/types/components/manage/Blocks/Block/Order/Item.test.d.ts +1 -0
  170. package/types/components/manage/Controlpanels/Users/UsersControlpanel.d.ts +2 -6
  171. package/types/components/manage/Controlpanels/Users/UsersControlpanel.ssr.test.d.ts +1 -0
  172. package/types/components/manage/Controlpanels/index.d.ts +1 -1
  173. package/webpack-plugins/webpack-less-plugin.js +1 -1
@@ -46,6 +46,14 @@ const messages = defineMessages({
46
46
  id: 'Cancel',
47
47
  defaultMessage: 'Cancel',
48
48
  },
49
+ dialogOpened: {
50
+ id: 'Pop-up opened: {title}',
51
+ defaultMessage: 'Pop-up opened: {title}',
52
+ },
53
+ dialogClosed: {
54
+ id: 'Pop-up closed.',
55
+ defaultMessage: 'Pop-up closed.',
56
+ },
49
57
  });
50
58
 
51
59
  /**
@@ -54,6 +62,7 @@ const messages = defineMessages({
54
62
  * @extends Component
55
63
  */
56
64
  class ModalForm extends Component {
65
+ static idCounter = 0;
57
66
  /**
58
67
  * Property types.
59
68
  * @property {Object} propTypes Property types.
@@ -110,17 +119,21 @@ class ModalForm extends Component {
110
119
  */
111
120
  constructor(props) {
112
121
  super(props);
122
+ this.headerId = `modal-title-${++ModalForm.idCounter}`;
113
123
  this.state = {
114
124
  currentTab: 0,
115
125
  errors: {},
116
126
  isFormPristine: true,
117
127
  formData: props.formData,
118
128
  };
129
+ this.modalRef = React.createRef();
130
+ this.announceRef = React.createRef();
119
131
  this.selectTab = this.selectTab.bind(this);
120
132
  this.onChangeField = this.onChangeField.bind(this);
121
133
  this.onBlurField = this.onBlurField.bind(this);
122
134
  this.onClickInput = this.onClickInput.bind(this);
123
135
  this.onSubmit = this.onSubmit.bind(this);
136
+ this.onKeyDown = this.onKeyDown.bind(this);
124
137
  }
125
138
 
126
139
  /**
@@ -209,12 +222,57 @@ class ModalForm extends Component {
209
222
  });
210
223
  }
211
224
 
225
+ onKeyDown(event) {
226
+ if (event.key !== 'Tab') return;
227
+ const modal = document.getElementById(this.headerId)?.closest('.ui.modal');
228
+ if (!modal) return;
229
+ const focusable = modal.querySelectorAll(
230
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',
231
+ );
232
+ if (!focusable.length) return;
233
+ const first = focusable[0];
234
+ const last = focusable[focusable.length - 1];
235
+ if (event.shiftKey) {
236
+ if (document.activeElement === first) {
237
+ event.preventDefault();
238
+ last.focus();
239
+ }
240
+ } else {
241
+ if (document.activeElement === last) {
242
+ event.preventDefault();
243
+ first.focus();
244
+ }
245
+ }
246
+ }
247
+
212
248
  /**
213
249
  * Component did update lifecycle handler
214
250
  * @param {Object} prevProps
215
251
  * @param {Object} prevState
216
252
  */
217
- async componentDidUpdate(prevProps, prevState) {
253
+ componentWillUnmount() {
254
+ document.removeEventListener('keydown', this.onKeyDown);
255
+ }
256
+
257
+ componentDidUpdate(prevProps, prevState) {
258
+ if (!prevProps.open && this.props.open) {
259
+ document.addEventListener('keydown', this.onKeyDown);
260
+ this.modalRef.current?.focus();
261
+ if (this.announceRef.current) {
262
+ this.announceRef.current.textContent = this.props.intl.formatMessage(
263
+ messages.dialogOpened,
264
+ { title: this.props.title },
265
+ );
266
+ }
267
+ }
268
+ if (prevProps.open && !this.props.open) {
269
+ document.removeEventListener('keydown', this.onKeyDown);
270
+ if (this.announceRef.current) {
271
+ this.announceRef.current.textContent = this.props.intl.formatMessage(
272
+ messages.dialogClosed,
273
+ );
274
+ }
275
+ }
218
276
  if (this.props.onChangeFormData) {
219
277
  if (!isEqual(prevState?.formData, this.state.formData)) {
220
278
  this.props.onChangeFormData(this.state.formData);
@@ -259,100 +317,120 @@ class ModalForm extends Component {
259
317
 
260
318
  const state_errors = keys(this.state.errors).length > 0;
261
319
  return (
262
- <Modal
263
- dimmer={this.props.dimmer}
264
- open={this.props.open}
265
- className={this.props.className}
266
- >
267
- <Header>{this.props.title}</Header>
268
- <Dimmer active={this.props.loading}>
269
- <Loader>
270
- {this.props.loadingMessage || (
271
- <FormattedMessage id="Loading" defaultMessage="Loading." />
272
- )}
273
- </Loader>
274
- </Dimmer>
275
- <Modal.Content scrolling>
276
- <UiForm
277
- method="post"
278
- onSubmit={this.onSubmit}
279
- error={state_errors || Boolean(this.props.submitError)}
280
- >
281
- {description}
282
- <Message error>
283
- {state_errors ? (
284
- <FormattedMessage
285
- id="There were some errors."
286
- defaultMessage="There were some errors."
287
- />
288
- ) : (
289
- ''
320
+ <>
321
+ {/* aria-live region outside Modal so it persists through open/close cycles */}
322
+ <div
323
+ ref={this.announceRef}
324
+ aria-live="assertive"
325
+ aria-atomic="true"
326
+ style={{
327
+ position: 'absolute',
328
+ width: '1px',
329
+ height: '1px',
330
+ overflow: 'hidden',
331
+ opacity: 0,
332
+ }}
333
+ />
334
+ <Modal
335
+ role="dialog"
336
+ dimmer={this.props.dimmer}
337
+ open={this.props.open}
338
+ className={this.props.className}
339
+ aria-labelledby={this.headerId}
340
+ aria-modal="true"
341
+ >
342
+ <Header id={this.headerId}>{this.props.title}</Header>
343
+ <Dimmer active={this.props.loading}>
344
+ <Loader>
345
+ {this.props.loadingMessage || (
346
+ <FormattedMessage id="Loading" defaultMessage="Loading." />
290
347
  )}
291
- <div>{this.props.submitError}</div>
292
- </Message>
293
- {schema.fieldsets?.length > 1 && (
294
- <Menu tabular stackable>
295
- {map(schema.fieldsets, (item, index) => (
296
- <Menu.Item
297
- name={item.id}
298
- index={index}
299
- key={item.id}
300
- active={this.state.currentTab === index}
301
- onClick={this.selectTab}
302
- >
303
- {item.title}
304
- </Menu.Item>
348
+ </Loader>
349
+ </Dimmer>
350
+ <Modal.Content scrolling>
351
+ {/* outline suppressed for programmatic focus via CSS :focus:not(:focus-visible) on .modal-focus-trap */}
352
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
353
+ <div ref={this.modalRef} tabIndex={-1} className="modal-focus-trap">
354
+ <UiForm
355
+ method="post"
356
+ onSubmit={this.onSubmit}
357
+ error={state_errors || Boolean(this.props.submitError)}
358
+ >
359
+ {description}
360
+ <Message error>
361
+ {state_errors ? (
362
+ <FormattedMessage
363
+ id="There were some errors."
364
+ defaultMessage="There were some errors."
365
+ />
366
+ ) : (
367
+ ''
368
+ )}
369
+ <div>{this.props.submitError}</div>
370
+ </Message>
371
+ {schema.fieldsets?.length > 1 && (
372
+ <Menu tabular stackable>
373
+ {map(schema.fieldsets, (item, index) => (
374
+ <Menu.Item
375
+ name={item.id}
376
+ index={index}
377
+ key={item.id}
378
+ active={this.state.currentTab === index}
379
+ onClick={this.selectTab}
380
+ >
381
+ {item.title}
382
+ </Menu.Item>
383
+ ))}
384
+ </Menu>
385
+ )}
386
+ {fields.map((field) => (
387
+ <Field
388
+ {...field}
389
+ key={field.id}
390
+ onBlur={this.onBlurField}
391
+ onClick={this.onClickInput}
392
+ error={this.state.errors[field.id]}
393
+ />
305
394
  ))}
306
- </Menu>
395
+ </UiForm>
396
+ </div>
397
+ </Modal.Content>
398
+ <Modal.Actions>
399
+ {onCancel && (
400
+ <Button
401
+ type="button"
402
+ basic
403
+ circular
404
+ secondary
405
+ aria-label={this.props.intl.formatMessage(messages.cancel)}
406
+ title={this.props.intl.formatMessage(messages.cancel)}
407
+ onClick={onCancel}
408
+ >
409
+ <Icon name={clearSVG} className="circled" size="30px" />
410
+ </Button>
307
411
  )}
308
- {fields.map((field) => (
309
- <Field
310
- {...field}
311
- key={field.id}
312
- onBlur={this.onBlurField}
313
- onClick={this.onClickInput}
314
- error={this.state.errors[field.id]}
315
- />
316
- ))}
317
- </UiForm>
318
- </Modal.Content>
319
- <Modal.Actions>
320
- <Button
321
- basic
322
- circular
323
- primary
324
- floated="right"
325
- aria-label={
326
- this.props.submitLabel
327
- ? this.props.submitLabel
328
- : this.props.intl.formatMessage(messages.save)
329
- }
330
- title={
331
- this.props.submitLabel
332
- ? this.props.submitLabel
333
- : this.props.intl.formatMessage(messages.save)
334
- }
335
- onClick={this.onSubmit}
336
- loading={this.props.loading}
337
- >
338
- <Icon name={aheadSVG} className="contents circled" size="30px" />
339
- </Button>
340
- {onCancel && (
341
412
  <Button
342
- type="button"
343
413
  basic
344
414
  circular
345
- secondary
346
- aria-label={this.props.intl.formatMessage(messages.cancel)}
347
- title={this.props.intl.formatMessage(messages.cancel)}
348
- floated="right"
349
- onClick={onCancel}
415
+ primary
416
+ aria-label={
417
+ this.props.submitLabel
418
+ ? this.props.submitLabel
419
+ : this.props.intl.formatMessage(messages.save)
420
+ }
421
+ title={
422
+ this.props.submitLabel
423
+ ? this.props.submitLabel
424
+ : this.props.intl.formatMessage(messages.save)
425
+ }
426
+ onClick={this.onSubmit}
427
+ loading={this.props.loading}
350
428
  >
351
- <Icon name={clearSVG} className="circled" size="30px" />
429
+ <Icon name={aheadSVG} className="contents circled" size="30px" />
352
430
  </Button>
353
- )}
354
- </Modal.Actions>
355
- </Modal>
431
+ </Modal.Actions>
432
+ </Modal>
433
+ </>
356
434
  );
357
435
  }
358
436
  }
@@ -155,6 +155,7 @@ const Sidebar = (props) => {
155
155
  key: 'documentTab',
156
156
  as: 'button',
157
157
  className: 'ui button',
158
+ type: 'button',
158
159
  content: type || intl.formatMessage(messages.document),
159
160
  },
160
161
  pane: (
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
+ import { defineMessages, useIntl } from 'react-intl';
3
4
  import Icon from '@plone/volto/components/theme/Icon/Icon';
4
5
 
5
6
  import successSVG from '@plone/volto/icons/ready.svg';
@@ -7,7 +8,28 @@ import infoSVG from '@plone/volto/icons/info.svg';
7
8
  import errorSVG from '@plone/volto/icons/error.svg';
8
9
  import warningSVG from '@plone/volto/icons/warning.svg';
9
10
 
11
+ const messages = defineMessages({
12
+ success: {
13
+ id: 'toast_type_success',
14
+ defaultMessage: 'Success',
15
+ },
16
+ error: {
17
+ id: 'toast_type_error',
18
+ defaultMessage: 'Error',
19
+ },
20
+ warning: {
21
+ id: 'toast_type_warning',
22
+ defaultMessage: 'Warning',
23
+ },
24
+ info: {
25
+ id: 'toast_type_info',
26
+ defaultMessage: 'Information',
27
+ },
28
+ });
29
+
10
30
  const Toast = (props) => {
31
+ const intl = useIntl();
32
+
11
33
  function getIcon(props) {
12
34
  if (props.info) {
13
35
  return infoSVG;
@@ -22,12 +44,24 @@ const Toast = (props) => {
22
44
  }
23
45
  }
24
46
 
47
+ function getTypeLabel(props) {
48
+ if (props.error) return intl.formatMessage(messages.error);
49
+ if (props.warning) return intl.formatMessage(messages.warning);
50
+ if (props.info) return intl.formatMessage(messages.info);
51
+ if (props.success) return intl.formatMessage(messages.success);
52
+ return null;
53
+ }
54
+
25
55
  const { title, content } = props;
56
+ const typeLabel = getTypeLabel(props);
26
57
 
27
58
  return (
28
59
  <>
29
- <Icon name={getIcon(props)} size="18px" />
60
+ <Icon name={getIcon(props)} size="18px" ariaHidden={true} />
30
61
  <div className="toast-inner-content">
62
+ {typeLabel && (
63
+ <span className="visually-hidden-volto">{typeLabel}</span>
64
+ )}
31
65
  {title && <h4>{title}</h4>}
32
66
  <div>{content}</div>
33
67
  </div>
@@ -1,14 +1,17 @@
1
1
  import React from 'react';
2
2
  import renderer from 'react-test-renderer';
3
+ import { IntlProvider } from 'react-intl';
3
4
  import Toast from './Toast';
4
5
 
5
6
  test('renders a Toast info component', () => {
6
7
  const component = renderer.create(
7
- <Toast
8
- info
9
- title="I'm a title"
10
- content="This is the content, lorem ipsum"
11
- />,
8
+ <IntlProvider locale="en">
9
+ <Toast
10
+ info
11
+ title="I'm a title"
12
+ content="This is the content, lorem ipsum"
13
+ />
14
+ </IntlProvider>,
12
15
  );
13
16
  const json = component.toJSON();
14
17
  expect(json).toMatchSnapshot();
@@ -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