@plone/volto 19.0.0-alpha.36 → 19.0.0-alpha.37

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 (176) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/locales/af/LC_MESSAGES/volto.po +29 -3
  4. package/locales/af.json +1 -1
  5. package/locales/ar/LC_MESSAGES/volto.po +29 -3
  6. package/locales/ar.json +1 -1
  7. package/locales/bg/LC_MESSAGES/volto.po +29 -3
  8. package/locales/bg.json +1 -1
  9. package/locales/bn/LC_MESSAGES/volto.po +29 -3
  10. package/locales/bn.json +1 -1
  11. package/locales/ca/LC_MESSAGES/volto.po +32 -6
  12. package/locales/ca.json +1 -1
  13. package/locales/cs/LC_MESSAGES/volto.po +30 -4
  14. package/locales/cs.json +1 -1
  15. package/locales/cy/LC_MESSAGES/volto.po +29 -3
  16. package/locales/cy.json +1 -1
  17. package/locales/da/LC_MESSAGES/volto.po +29 -3
  18. package/locales/da.json +1 -1
  19. package/locales/de/LC_MESSAGES/volto.po +32 -6
  20. package/locales/de.json +1 -1
  21. package/locales/el/LC_MESSAGES/volto.po +29 -3
  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 +29 -3
  26. package/locales/en_AU.json +1 -1
  27. package/locales/en_GB/LC_MESSAGES/volto.po +29 -3
  28. package/locales/en_GB.json +1 -1
  29. package/locales/eo/LC_MESSAGES/volto.po +29 -3
  30. package/locales/eo.json +1 -1
  31. package/locales/es/LC_MESSAGES/volto.po +30 -5
  32. package/locales/es.json +1 -1
  33. package/locales/et/LC_MESSAGES/volto.po +29 -3
  34. package/locales/et.json +1 -1
  35. package/locales/eu/LC_MESSAGES/volto.po +30 -5
  36. package/locales/eu.json +1 -1
  37. package/locales/fa/LC_MESSAGES/volto.po +29 -3
  38. package/locales/fa.json +1 -1
  39. package/locales/fi/LC_MESSAGES/volto.po +30 -4
  40. package/locales/fi.json +1 -1
  41. package/locales/fr/LC_MESSAGES/volto.po +208 -183
  42. package/locales/fr.json +1 -1
  43. package/locales/fu/LC_MESSAGES/volto.po +29 -3
  44. package/locales/fu.json +1 -1
  45. package/locales/gl/LC_MESSAGES/volto.po +27 -2
  46. package/locales/gl.json +1 -1
  47. package/locales/he/LC_MESSAGES/volto.po +29 -3
  48. package/locales/he.json +1 -1
  49. package/locales/hi/LC_MESSAGES/volto.po +34 -8
  50. package/locales/hi.json +1 -1
  51. package/locales/hr/LC_MESSAGES/volto.po +30 -4
  52. package/locales/hr.json +1 -1
  53. package/locales/hu/LC_MESSAGES/volto.po +29 -3
  54. package/locales/hu.json +1 -1
  55. package/locales/hy/LC_MESSAGES/volto.po +29 -3
  56. package/locales/hy.json +1 -1
  57. package/locales/id/LC_MESSAGES/volto.po +29 -3
  58. package/locales/id.json +1 -1
  59. package/locales/it/LC_MESSAGES/volto.po +29 -4
  60. package/locales/it.json +1 -1
  61. package/locales/ja/LC_MESSAGES/volto.po +29 -3
  62. package/locales/ja.json +1 -1
  63. package/locales/ka/LC_MESSAGES/volto.po +29 -3
  64. package/locales/ka.json +1 -1
  65. package/locales/kn/LC_MESSAGES/volto.po +29 -3
  66. package/locales/kn.json +1 -1
  67. package/locales/ko/LC_MESSAGES/volto.po +29 -3
  68. package/locales/ko.json +1 -1
  69. package/locales/lt/LC_MESSAGES/volto.po +30 -4
  70. package/locales/lt.json +1 -1
  71. package/locales/lv/LC_MESSAGES/volto.po +29 -3
  72. package/locales/lv.json +1 -1
  73. package/locales/mi/LC_MESSAGES/volto.po +29 -3
  74. package/locales/mi.json +1 -1
  75. package/locales/mk/LC_MESSAGES/volto.po +29 -3
  76. package/locales/mk.json +1 -1
  77. package/locales/my/LC_MESSAGES/volto.po +29 -3
  78. package/locales/my.json +1 -1
  79. package/locales/nb_NO/LC_MESSAGES/volto.po +29 -3
  80. package/locales/nb_NO.json +1 -1
  81. package/locales/nl/LC_MESSAGES/volto.po +69 -43
  82. package/locales/nl.json +1 -1
  83. package/locales/nn/LC_MESSAGES/volto.po +29 -3
  84. package/locales/nn.json +1 -1
  85. package/locales/pl/LC_MESSAGES/volto.po +30 -4
  86. package/locales/pl.json +1 -1
  87. package/locales/pt/LC_MESSAGES/volto.po +30 -4
  88. package/locales/pt.json +1 -1
  89. package/locales/pt_BR/LC_MESSAGES/volto.po +54 -29
  90. package/locales/pt_BR.json +1 -1
  91. package/locales/rm/LC_MESSAGES/volto.po +29 -3
  92. package/locales/rm.json +1 -1
  93. package/locales/ro/LC_MESSAGES/volto.po +30 -5
  94. package/locales/ro.json +1 -1
  95. package/locales/ru/LC_MESSAGES/volto.po +30 -4
  96. package/locales/ru.json +1 -1
  97. package/locales/sk/LC_MESSAGES/volto.po +30 -4
  98. package/locales/sk.json +1 -1
  99. package/locales/sl/LC_MESSAGES/volto.po +29 -3
  100. package/locales/sl.json +1 -1
  101. package/locales/sm/LC_MESSAGES/volto.po +29 -3
  102. package/locales/sm.json +1 -1
  103. package/locales/sq/LC_MESSAGES/volto.po +29 -3
  104. package/locales/sq.json +1 -1
  105. package/locales/sr/LC_MESSAGES/volto.po +30 -4
  106. package/locales/sr.json +1 -1
  107. package/locales/sr@cyrl/LC_MESSAGES/volto.po +29 -3
  108. package/locales/sr@cyrl.json +1 -1
  109. package/locales/sr@latn/LC_MESSAGES/volto.po +29 -3
  110. package/locales/sr@latn.json +1 -1
  111. package/locales/sv/LC_MESSAGES/volto.po +31 -5
  112. package/locales/sv.json +1 -1
  113. package/locales/ta/LC_MESSAGES/volto.po +30 -5
  114. package/locales/ta.json +1 -1
  115. package/locales/te/LC_MESSAGES/volto.po +29 -3
  116. package/locales/te.json +1 -1
  117. package/locales/th/LC_MESSAGES/volto.po +29 -3
  118. package/locales/th.json +1 -1
  119. package/locales/to/LC_MESSAGES/volto.po +29 -3
  120. package/locales/to.json +1 -1
  121. package/locales/tr/LC_MESSAGES/volto.po +29 -4
  122. package/locales/tr.json +1 -1
  123. package/locales/uk/LC_MESSAGES/volto.po +30 -4
  124. package/locales/uk.json +1 -1
  125. package/locales/vi/LC_MESSAGES/volto.po +29 -3
  126. package/locales/vi.json +1 -1
  127. package/locales/volto.pot +26 -1
  128. package/locales/zh_CN/LC_MESSAGES/volto.po +29 -4
  129. package/locales/zh_CN.json +1 -1
  130. package/locales/zh_Hant/LC_MESSAGES/volto.po +29 -3
  131. package/locales/zh_Hant.json +1 -1
  132. package/locales/zh_Hant_HK/LC_MESSAGES/volto.po +29 -3
  133. package/locales/zh_Hant_HK.json +1 -1
  134. package/package.json +8 -8
  135. package/src/components/manage/BlockChooser/BlockChooser.jsx +7 -10
  136. package/src/components/manage/Blocks/Block/Edit.jsx +19 -10
  137. package/src/components/manage/Blocks/Block/Order/Item.jsx +9 -4
  138. package/src/components/manage/Contents/DropZoneContent.jsx +1 -0
  139. package/src/components/manage/Controlpanels/BlockType.tsx +2 -3
  140. package/src/components/manage/Controlpanels/ContentTypes.jsx +9 -2
  141. package/src/components/manage/Controlpanels/Users/UsersControlpanel.jsx +58 -5
  142. package/src/components/manage/Controlpanels/Users/UsersControlpanel.ssr.test.jsx +624 -0
  143. package/src/components/manage/Controlpanels/Users/UsersControlpanel.test.jsx +8 -0
  144. package/src/components/manage/Form/Form.jsx +6 -1
  145. package/src/components/manage/Form/ModalForm.jsx +164 -88
  146. package/src/components/manage/Sidebar/ObjectBrowser.jsx +7 -0
  147. package/src/components/manage/Sidebar/ObjectBrowserBody.jsx +7 -3
  148. package/src/components/manage/Sidebar/ObjectBrowserBody.test.jsx +52 -0
  149. package/src/components/manage/Sidebar/Sidebar.jsx +2 -0
  150. package/src/components/manage/Toolbar/Toolbar.jsx +89 -7
  151. package/src/components/manage/Widgets/ObjectBrowserWidget.jsx +1 -0
  152. package/src/components/manage/Widgets/TokenWidget.jsx +142 -186
  153. package/src/components/theme/Search/Search.jsx +218 -328
  154. package/src/components/theme/Search/Search.test.jsx +5 -10
  155. package/src/components/theme/Sitemap/Sitemap.jsx +22 -30
  156. package/src/components/theme/Sitemap/Sitemap.test.jsx +18 -0
  157. package/src/config/index.js +1 -0
  158. package/src/helpers/I18n/I18n.test.ts +44 -0
  159. package/src/helpers/I18n/I18n.ts +31 -0
  160. package/src/helpers/index.js +1 -0
  161. package/theme/themes/pastanaga/collections/form.overrides +21 -0
  162. package/theme/themes/pastanaga/elements/button.overrides +30 -3
  163. package/types/components/manage/Controlpanels/Relations/RelationsMatrix.d.ts +1 -1
  164. package/types/components/manage/Controlpanels/Users/UsersControlpanel.d.ts +2 -6
  165. package/types/components/manage/Controlpanels/Users/UsersControlpanel.ssr.test.d.ts +1 -0
  166. package/types/components/manage/Controlpanels/index.d.ts +1 -1
  167. package/types/components/manage/Multilingual/ManageTranslations.d.ts +1 -1
  168. package/types/components/manage/Sidebar/ObjectBrowser.d.ts +1 -1
  169. package/types/components/manage/Sidebar/ObjectBrowserBody.test.d.ts +1 -0
  170. package/types/components/manage/Widgets/ImageWidget.d.ts +1 -1
  171. package/types/components/manage/Widgets/InternalUrlWidget.d.ts +1 -1
  172. package/types/components/manage/Widgets/UrlWidget.d.ts +1 -1
  173. package/types/components/manage/Widgets/index.d.ts +2 -2
  174. package/types/components/theme/Search/Search.d.ts +1 -1
  175. package/types/helpers/I18n/I18n.d.ts +20 -0
  176. package/types/helpers/index.d.ts +1 -0
@@ -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,118 @@ 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
+ secondary
404
+ aria-label={this.props.intl.formatMessage(messages.cancel)}
405
+ title={this.props.intl.formatMessage(messages.cancel)}
406
+ onClick={onCancel}
407
+ >
408
+ <Icon name={clearSVG} className="circled" size="30px" />
409
+ </Button>
307
410
  )}
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
411
  <Button
342
- type="button"
343
412
  basic
344
- 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}
413
+ primary
414
+ aria-label={
415
+ this.props.submitLabel
416
+ ? this.props.submitLabel
417
+ : this.props.intl.formatMessage(messages.save)
418
+ }
419
+ title={
420
+ this.props.submitLabel
421
+ ? this.props.submitLabel
422
+ : this.props.intl.formatMessage(messages.save)
423
+ }
424
+ onClick={this.onSubmit}
425
+ loading={this.props.loading}
350
426
  >
351
- <Icon name={clearSVG} className="circled" size="30px" />
427
+ <Icon name={aheadSVG} className="contents circled" size="30px" />
352
428
  </Button>
353
- )}
354
- </Modal.Actions>
355
- </Modal>
429
+ </Modal.Actions>
430
+ </Modal>
431
+ </>
356
432
  );
357
433
  }
358
434
  }
@@ -58,6 +58,7 @@ const withObjectBrowser = (WrappedComponent) =>
58
58
  selectableTypes,
59
59
  maximumSelectionSize,
60
60
  currentPath,
61
+ initialPath,
61
62
  onlyFolderishSelectable,
62
63
  } = {}) =>
63
64
  this.setState(() => ({
@@ -71,6 +72,7 @@ const withObjectBrowser = (WrappedComponent) =>
71
72
  selectableTypes,
72
73
  maximumSelectionSize,
73
74
  currentPath,
75
+ initialPath,
74
76
  onlyFolderishSelectable,
75
77
  }));
76
78
 
@@ -82,6 +84,10 @@ const withObjectBrowser = (WrappedComponent) =>
82
84
  this.props.pathname ||
83
85
  this.props.location?.pathname;
84
86
 
87
+ let initialPath = this.state?.initialPath
88
+ ? getBaseUrl(this.state.initialPath)
89
+ : null;
90
+
85
91
  return (
86
92
  <>
87
93
  <WrappedComponent
@@ -105,6 +111,7 @@ const withObjectBrowser = (WrappedComponent) =>
105
111
  : this.props.data
106
112
  }
107
113
  contextURL={getBaseUrl(contextURL)}
114
+ initialPath={initialPath}
108
115
  closeObjectBrowser={this.closeObjectBrowser}
109
116
  mode={this.state.mode}
110
117
  onSelectItem={this.state.onSelectItem}
@@ -84,6 +84,7 @@ class ObjectBrowserBody extends Component {
84
84
  onSelectItem: PropTypes.func,
85
85
  dataName: PropTypes.string,
86
86
  maximumSelectionSize: PropTypes.number,
87
+ initialPath: PropTypes.string,
87
88
  contextURL: PropTypes.string,
88
89
  searchableTypes: PropTypes.arrayOf(PropTypes.string),
89
90
  onlyFolderishSelectable: PropTypes.bool,
@@ -113,18 +114,21 @@ class ObjectBrowserBody extends Component {
113
114
  */
114
115
  constructor(props) {
115
116
  super(props);
117
+ const defaultMultiplePath = props.initialPath || '/';
116
118
  this.state = {
117
119
  currentFolder:
118
- this.props.mode === 'multiple' ? '/' : this.props.contextURL || '/',
120
+ this.props.mode === 'multiple'
121
+ ? defaultMultiplePath
122
+ : this.props.contextURL || '/',
119
123
  currentImageFolder:
120
124
  this.props.mode === 'multiple'
121
- ? '/'
125
+ ? defaultMultiplePath
122
126
  : this.props.mode === 'image' && this.props.data?.url
123
127
  ? getParentURL(this.props.data.url)
124
128
  : '/',
125
129
  currentLinkFolder:
126
130
  this.props.mode === 'multiple'
127
- ? '/'
131
+ ? defaultMultiplePath
128
132
  : this.props.mode === 'link' && this.props.data?.href
129
133
  ? getParentURL(this.props.data.href)
130
134
  : '/',
@@ -0,0 +1,52 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import configureStore from 'redux-mock-store';
4
+ import { Provider } from 'react-intl-redux';
5
+ import ObjectBrowserBody from './ObjectBrowserBody';
6
+
7
+ const mockStore = configureStore();
8
+
9
+ const baseState = {
10
+ search: { subrequests: {} },
11
+ intl: { locale: 'en', messages: {} },
12
+ };
13
+
14
+ const baseProps = {
15
+ block: 'test-block',
16
+ data: {},
17
+ closeObjectBrowser: () => {},
18
+ onChangeBlock: () => {},
19
+ };
20
+
21
+ const getInitialSearchPath = (actions) => {
22
+ const action = actions.find((a) => a.type === 'SEARCH_CONTENT');
23
+ return action?.request?.path?.split('/@search')[0];
24
+ };
25
+
26
+ describe('ObjectBrowserBody', () => {
27
+ it('uses initialPath as the default folder when mode=multiple', () => {
28
+ const store = mockStore(baseState);
29
+ render(
30
+ <Provider store={store}>
31
+ <ObjectBrowserBody
32
+ {...baseProps}
33
+ mode="multiple"
34
+ initialPath="/company/team"
35
+ />
36
+ </Provider>,
37
+ );
38
+
39
+ expect(getInitialSearchPath(store.getActions())).toBe('/company/team');
40
+ });
41
+
42
+ it('defaults to root when mode=multiple and initialPath is not provided', () => {
43
+ const store = mockStore(baseState);
44
+ render(
45
+ <Provider store={store}>
46
+ <ObjectBrowserBody {...baseProps} mode="multiple" />
47
+ </Provider>,
48
+ );
49
+
50
+ expect(getInitialSearchPath(store.getActions())).toBe('/');
51
+ });
52
+ });
@@ -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: (
@@ -170,6 +171,7 @@ const Sidebar = (props) => {
170
171
  key: 'blockTab',
171
172
  as: 'button',
172
173
  className: 'ui button',
174
+ type: 'button',
173
175
  content: intl.formatMessage(messages.block),
174
176
  },
175
177
  pane: (
@@ -108,6 +108,18 @@ const messages = defineMessages({
108
108
  id: 'Unlock',
109
109
  defaultMessage: 'Unlock',
110
110
  },
111
+ menuOpened: {
112
+ id: 'Menu opened',
113
+ defaultMessage: 'Menu opened',
114
+ },
115
+ menuClosed: {
116
+ id: 'Menu closed',
117
+ defaultMessage: 'Menu closed',
118
+ },
119
+ focusOn: {
120
+ id: 'Focus on',
121
+ defaultMessage: 'Focus on',
122
+ },
111
123
  });
112
124
 
113
125
  let toolbarComponents = {
@@ -185,6 +197,7 @@ class Toolbar extends Component {
185
197
  toolbarRef = React.createRef();
186
198
  toolbarWindow = React.createRef();
187
199
  buttonRef = React.createRef();
200
+ announceRef = React.createRef();
188
201
 
189
202
  constructor(props) {
190
203
  super(props);
@@ -305,10 +318,40 @@ class Toolbar extends Component {
305
318
  }
306
319
  // PersonalTools always shows at bottom
307
320
  if (selector === 'personalTools') {
308
- this.setState((state) => ({
309
- showMenu: !state.showMenu,
310
- menuStyle: { bottom: 0 },
311
- }));
321
+ this.setState(
322
+ (state) => ({
323
+ showMenu: !state.showMenu,
324
+ menuStyle: { bottom: 0 },
325
+ }),
326
+ () => {
327
+ // Scoped only to personalTools — does not affect other toolbar flows
328
+ const candidates =
329
+ this.toolbarWindow.current?.querySelectorAll(
330
+ 'a, button, input, [tabindex]:not([tabindex="-1"])',
331
+ ) ?? [];
332
+ const firstVisible = Array.from(candidates).find((el) => {
333
+ const style = window.getComputedStyle(el);
334
+ return style.display !== 'none' && style.visibility !== 'hidden';
335
+ });
336
+ firstVisible?.focus();
337
+
338
+ // Announce to screen readers: menu opened + which element received focus
339
+ if (this.announceRef.current) {
340
+ const focusedLabel =
341
+ firstVisible?.getAttribute('aria-label') ||
342
+ firstVisible?.textContent?.trim() ||
343
+ '';
344
+ this.announceRef.current.textContent = '';
345
+ setTimeout(() => {
346
+ if (this.announceRef.current) {
347
+ this.announceRef.current.textContent = focusedLabel
348
+ ? `${this.props.intl.formatMessage(messages.menuOpened)}, ${this.props.intl.formatMessage(messages.focusOn)} ${focusedLabel}`
349
+ : this.props.intl.formatMessage(messages.menuOpened);
350
+ }
351
+ }, 100);
352
+ }
353
+ },
354
+ );
312
355
  } else if (selector === 'more') {
313
356
  this.setState((state) => ({
314
357
  showMenu: !state.showMenu,
@@ -337,10 +380,22 @@ class Toolbar extends Component {
337
380
 
338
381
  handleClickOutside = (e) => {
339
382
  const target = e.target;
340
- if (this.pusher && doesNodeContainClick(this.pusher, e)) return;
341
383
 
342
- // if the click is on the same button, do not close the menu as it
343
- // may be handled by the toggleMenu action
384
+ if (this.pusher && doesNodeContainClick(this.pusher, e)) {
385
+ return;
386
+ }
387
+
388
+ if (
389
+ this.toolbarRef.current &&
390
+ doesNodeContainClick(this.toolbarRef.current, e)
391
+ ) {
392
+ return;
393
+ }
394
+
395
+ if (target.closest('.ui.modal') || target.closest('.ui.dimmer')) {
396
+ return;
397
+ }
398
+
344
399
  const button =
345
400
  doesNodeContainClick(this.toolbarRef.current, e) &&
346
401
  this.findAncestor(target, 'button');
@@ -376,12 +431,39 @@ class Toolbar extends Component {
376
431
  <BodyClass
377
432
  className={expanded ? 'has-toolbar' : 'has-toolbar-collapsed'}
378
433
  />
434
+ <span
435
+ aria-live="assertive"
436
+ aria-atomic="true"
437
+ role="status"
438
+ className="visually-hidden"
439
+ ref={this.announceRef}
440
+ />
379
441
  <div
380
442
  style={this.state.menuStyle}
381
443
  className={
382
444
  this.state.showMenu ? 'toolbar-content show' : 'toolbar-content'
383
445
  }
384
446
  ref={this.toolbarWindow}
447
+ onBlur={(e) => {
448
+ if (!this.toolbarWindow.current?.contains(e.relatedTarget)) {
449
+ this.toolbarRef.current
450
+ ?.querySelector('button.toolbar-handler-button')
451
+ ?.focus();
452
+
453
+ this.closeMenu();
454
+
455
+ if (this.announceRef.current) {
456
+ this.announceRef.current.textContent = '';
457
+ // Timeout to allow the screen reader to pick up the change in content after the menu is closed
458
+ setTimeout(() => {
459
+ if (this.announceRef.current) {
460
+ this.announceRef.current.textContent =
461
+ this.props.intl.formatMessage(messages.menuClosed);
462
+ }
463
+ }, 100);
464
+ }
465
+ }
466
+ }}
385
467
  >
386
468
  {this.state.showMenu && (
387
469
  // This sets the scroll locker in the body tag in mobile
@@ -307,6 +307,7 @@ export class ObjectBrowserWidgetComponent extends Component {
307
307
  this.props.openObjectBrowser({
308
308
  mode: this.props.mode,
309
309
  currentPath: this.props.initialPath || this.props.location.pathname,
310
+ initialPath: this.props.initialPath,
310
311
  propDataName: 'value',
311
312
  onSelectItem: (url, item) => {
312
313
  this.onChange(item);