typo3-typescript-sources 14.3.6 → 14.3.8

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 (54) hide show
  1. package/.last-synced-tag +1 -1
  2. package/README-TYPO3.md +1 -1
  3. package/README.md +1 -1
  4. package/backend/column-selector-button.ts +5 -2
  5. package/backend/drag-uploader.ts +78 -86
  6. package/backend/element/contextual-record-edit-trigger.ts +9 -1
  7. package/backend/form-engine/element/extra/select-box-filter.ts +8 -5
  8. package/backend/form-engine/element/online-media-form-element.ts +3 -3
  9. package/backend/form-engine/element/select-tree.ts +16 -2
  10. package/backend/form-engine/field-control/add-record.ts +27 -1
  11. package/backend/form-engine.ts +88 -13
  12. package/backend/layout-module/column-restriction.ts +61 -0
  13. package/backend/layout-module/drag-drop.ts +12 -0
  14. package/backend/layout-module/paste.ts +7 -0
  15. package/backend/localization/localization-wizard.ts +3 -0
  16. package/backend/localization/steps/handler-selection-step.ts +12 -17
  17. package/backend/localization/steps/mode-step.ts +9 -14
  18. package/backend/localization/steps/source-language-step.ts +8 -13
  19. package/backend/localization/steps/target-language-step.ts +12 -17
  20. package/backend/login-refresh.ts +1 -9
  21. package/backend/modal.ts +162 -6
  22. package/backend/notification.ts +90 -6
  23. package/backend/online-media.ts +1 -3
  24. package/backend/record-download-button.ts +4 -5
  25. package/backend/recordlist.ts +5 -0
  26. package/backend/tests/fixtures/modal-autofocus.html +1 -0
  27. package/backend/tests/form-engine/element/extra/select-box-filter-test.ts +215 -0
  28. package/backend/tests/form-engine/element/select-tree-test.ts +121 -0
  29. package/backend/tests/modal-test.ts +91 -0
  30. package/backend/tests/notification-test.ts +176 -0
  31. package/backend/tree/page-tree-element.ts +8 -1
  32. package/backend/wizard/steps/finisher-step.ts +2 -2
  33. package/backend/wizard/wizard.ts +11 -4
  34. package/core/ajax/ajax-request.ts +18 -0
  35. package/core/tests/ajax/ajax-request-test.ts +1 -1
  36. package/core/tests/utility/text-normalizer-test.ts +138 -0
  37. package/core/utility/text-normalizer.ts +98 -0
  38. package/dashboard/dashboard.ts +3 -8
  39. package/filelist/file-list-rename-handler.ts +3 -6
  40. package/filelist/file-list-replace-handler.ts +3 -6
  41. package/filelist/file-list.ts +3 -4
  42. package/form/backend/form-editor/modals-component.ts +114 -5
  43. package/form/backend/form-editor/view-model.ts +1 -1
  44. package/form/backend/form-wizard/steps/settings-step.ts +6 -3
  45. package/package.json +2 -2
  46. package/rte-ckeditor/legacy/ckeditor5-bundle.ts +0 -1
  47. package/rte-ckeditor/plugin/typo3-link.ts +7 -9
  48. package/rte-ckeditor/rte-link-browser.ts +1 -1
  49. package/scheduler/scheduler-add-group.ts +4 -12
  50. package/workspaces/backend.ts +7 -2
  51. package/workspaces/preview.ts +39 -0
  52. package/workspaces/renderable/comment-view.ts +4 -2
  53. package/workspaces/renderable/record-table.ts +134 -80
  54. package/workspaces/workspaces.ts +65 -12
@@ -11,7 +11,7 @@
11
11
  * The TYPO3 project - inspiring people to share!
12
12
  */
13
13
 
14
- import { LitElement, html, type TemplateResult } from 'lit';
14
+ import { LitElement, html, nothing, type TemplateResult } from 'lit';
15
15
  import { customElement, property, state } from 'lit/decorators.js';
16
16
  import { classMap } from 'lit/directives/class-map.js';
17
17
  import { ifDefined } from 'lit/directives/if-defined.js';
@@ -119,16 +119,20 @@ class Notification {
119
119
  if (this.messageContainer === null || document.getElementById('alert-container') === null) {
120
120
  this.messageContainer = document.createElement('div');
121
121
  this.messageContainer.setAttribute('id', 'alert-container');
122
+ this.messageContainer.setAttribute('popover', 'manual');
122
123
  this.notificationList = document.createElement('div');
123
124
  this.notificationList.setAttribute('class', 'alert-list');
124
125
  // Enable focusing for keyboard scrolling (accessibility)
125
126
  this.notificationList.setAttribute('tabindex', '0');
127
+ this.notificationList.setAttribute('aria-live', 'polite');
126
128
  this.messageContainer.appendChild(this.notificationList);
127
129
 
128
130
  this.clearAllButton = <ClearNotificationMessages>document.createElement('typo3-notification-clear-all');
129
131
  this.containerItemVisibility();
130
132
  this.messageContainer.prepend(this.clearAllButton);
131
- document.body.appendChild(this.messageContainer);
133
+ const target = document.querySelector('typo3-backend-modal:last-of-type .alert-container') ?? document.body;
134
+ target.appendChild(this.messageContainer);
135
+ this.messageContainer.showPopover();
132
136
 
133
137
  document.addEventListener('typo3-notification-open', () => {
134
138
  this.totalNotifications++;
@@ -165,7 +169,20 @@ class Notification {
165
169
 
166
170
  protected static containerItemVisibility() {
167
171
  this.clearAllButton.hidden = this.totalNotifications < this.showClearAllButtonCount;
168
- this.messageContainer.hidden = this.totalNotifications === 0;
172
+ try {
173
+ // Always hide first to ensure we are moved into the foremost top-layer
174
+ this.messageContainer.hidePopover();
175
+ } catch (e: unknown) {
176
+ if (e instanceof DOMException && e.name === 'InvalidStateError') {
177
+ // Ignored, since popover does not signal showing state,
178
+ // but may throw when `hidePopover()` is called on a hidden element
179
+ } else {
180
+ throw e;
181
+ }
182
+ }
183
+ if (this.totalNotifications > 0) {
184
+ this.messageContainer.showPopover();
185
+ }
169
186
  }
170
187
  }
171
188
 
@@ -204,6 +221,13 @@ export class NotificationMessage extends LitElement {
204
221
 
205
222
  @state() executingAction: number = -1;
206
223
 
224
+ private dismissTimer: number | null = null;
225
+ private remainingDuration: number = 0;
226
+ private timerStartedAt: number = 0;
227
+ private dismissalArmed: boolean = false;
228
+ private pointerInside: boolean = false;
229
+ private focusInside: boolean = false;
230
+
207
231
  public override async firstUpdated(): Promise<void> {
208
232
  document.addEventListener('typo3-notification-clear-all', async () => {
209
233
  this.clear();
@@ -212,15 +236,42 @@ export class NotificationMessage extends LitElement {
212
236
  const event = new CustomEvent('typo3-notification-open', { bubbles: true, composed: true });
213
237
  this.dispatchEvent(event);
214
238
 
239
+ if (this.notificationDuration > 0) {
240
+ // Reading a message or reaching its actions takes the time it takes, so the countdown
241
+ // pauses while the notification is pointed at or focused. This is tracked from the start,
242
+ // the pointer may already rest where the notification appears.
243
+ this.remainingDuration = this.notificationDuration * 1000;
244
+ this.addEventListener('mouseenter', (): void => {
245
+ this.pointerInside = true;
246
+ this.pauseDismissal();
247
+ });
248
+ this.addEventListener('focusin', (): void => {
249
+ this.focusInside = true;
250
+ this.pauseDismissal();
251
+ });
252
+ this.addEventListener('mouseleave', (): void => {
253
+ this.pointerInside = false;
254
+ this.resumeDismissal();
255
+ });
256
+ this.addEventListener('focusout', (event: FocusEvent): void => {
257
+ this.focusInside = event.relatedTarget instanceof Node && this.contains(event.relatedTarget);
258
+ this.resumeDismissal();
259
+ });
260
+ }
261
+
215
262
  await new Promise(resolve => window.setTimeout(resolve, 200));
216
263
  await this.requestUpdate();
217
264
  if (this.notificationDuration > 0) {
218
- await new Promise(resolve => window.setTimeout(resolve, this.notificationDuration * 1000));
219
- this.clear();
265
+ this.dismissalArmed = true;
266
+ this.resumeDismissal();
220
267
  }
221
268
  }
222
269
 
223
270
  public async clear(): Promise<void> {
271
+ if (this.dismissTimer !== null) {
272
+ window.clearTimeout(this.dismissTimer);
273
+ this.dismissTimer = null;
274
+ }
224
275
  this.dispatchEvent(
225
276
  new CustomEvent('typo3-notification-clear', { bubbles: true, composed: true })
226
277
  );
@@ -307,12 +358,18 @@ export class NotificationMessage extends LitElement {
307
358
  ${this.actions.map((action, index) => html`
308
359
  <a href="#"
309
360
  title="${action.label}"
361
+ aria-disabled="${this.executingAction >= 0 ? 'true' : nothing}"
310
362
  @click="${async (event: PointerEvent) => {
311
363
  event.preventDefault();
364
+ // Pointer events are disabled while executing, keyboard activation is not
365
+ if (this.executingAction >= 0) {
366
+ return;
367
+ }
368
+ const target = event.currentTarget as HTMLAnchorElement;
312
369
  this.executingAction = index;
313
370
  await this.updateComplete;
314
371
  if ('action' in action) {
315
- await action.action.execute(event.currentTarget as HTMLAnchorElement);
372
+ await action.action.execute(target);
316
373
  }
317
374
  this.clear();
318
375
  }}"
@@ -328,6 +385,33 @@ export class NotificationMessage extends LitElement {
328
385
  `;
329
386
  /* eslint-enable @stylistic/indent */
330
387
  }
388
+
389
+ private pauseDismissal(): void {
390
+ if (this.dismissTimer === null) {
391
+ return;
392
+ }
393
+ window.clearTimeout(this.dismissTimer);
394
+ this.dismissTimer = null;
395
+ this.remainingDuration -= Date.now() - this.timerStartedAt;
396
+ }
397
+
398
+ private resumeDismissal(): void {
399
+ if (!this.dismissalArmed || this.dismissTimer !== null || !this.isConnected || this.pointerInside || this.focusInside) {
400
+ return;
401
+ }
402
+ this.startDismissal();
403
+ }
404
+
405
+ private startDismissal(): void {
406
+ this.timerStartedAt = Date.now();
407
+ this.dismissTimer = window.setTimeout((): void => {
408
+ this.dismissTimer = null;
409
+ // An action being executed clears the notification once it is done
410
+ if (this.executingAction < 0) {
411
+ this.clear();
412
+ }
413
+ }, Math.max(this.remainingDuration, 0));
414
+ }
331
415
  }
332
416
 
333
417
  declare global {
@@ -106,9 +106,7 @@ class OnlineMedia {
106
106
  text: btnSubmit,
107
107
  btnClass: 'btn btn-primary',
108
108
  name: 'ok',
109
- trigger: (): void => {
110
- onlineMediaForm.querySelector('form').requestSubmit();
111
- },
109
+ form: 'online-media-form',
112
110
  }],
113
111
  });
114
112
  }
@@ -65,17 +65,16 @@ export class RecordDownloadButton extends PseudoButtonLitElement {
65
65
  text: this.ok || listLabels.get('button.ok'),
66
66
  btnClass: 'btn-primary',
67
67
  name: 'download',
68
- trigger: (): void => {
69
- const form: HTMLFormElement = modal.querySelector('form');
70
- form?.submit();
71
- modal.hideModal();
72
- }
68
+ form: 'downloadSettingsForm',
73
69
  }
74
70
  ],
75
71
  ajaxCallback: (): void => {
72
+ const form: HTMLFormElement = modal.querySelector('form');
76
73
  const formatSelect: HTMLSelectElement = modal.querySelector(Selectors.formatSelector);
77
74
  const formatOptions: NodeListOf<HTMLDivElement> = modal.querySelectorAll(Selectors.formatOptions);
78
75
 
76
+ form?.addEventListener('submit', (): void => modal.hideModal());
77
+
79
78
  if (formatSelect === null || !formatOptions.length) {
80
79
  // Return in case elements do not exist in the ajax loaded modal content
81
80
  return;
@@ -274,6 +274,11 @@ class Recordlist {
274
274
 
275
275
  private readonly registerPaginationEvents = (): void => {
276
276
  document.querySelectorAll('.t3js-recordlist-paging').forEach((trigger: HTMLInputElement) => {
277
+ trigger.addEventListener('keydown', (e: KeyboardEvent) => {
278
+ if (e.key === 'Enter') {
279
+ e.preventDefault();
280
+ }
281
+ });
277
282
  trigger.addEventListener('keyup', (e: KeyboardEvent) => {
278
283
  e.preventDefault();
279
284
  let value = Number(trigger.value);
@@ -0,0 +1 @@
1
+ <form id="modal-ajax-form"><input name="filter" autofocus></form>
@@ -0,0 +1,215 @@
1
+ /*
2
+ * This file is part of the TYPO3 CMS project.
3
+ *
4
+ * It is free software; you can redistribute it and/or modify it under
5
+ * the terms of the GNU General Public License, either version 2
6
+ * of the License, or any later version.
7
+ *
8
+ * For the full copyright and license information, please read the
9
+ * LICENSE.txt file that was distributed with this source code.
10
+ *
11
+ * The TYPO3 project - inspiring people to share!
12
+ */
13
+
14
+ import SelectBoxFilter from '@typo3/backend/form-engine/element/extra/select-box-filter.js';
15
+ import { expect } from '@open-wc/testing';
16
+ import type { } from 'mocha';
17
+
18
+ describe('@typo3/backend/form-engine/element/extra/select-box-filter', () => {
19
+ let root: HTMLElement;
20
+ let filterField: HTMLInputElement | HTMLSelectElement;
21
+
22
+ const optionLabels: string[] = ['Crème brûlée', 'Müller', 'Sirup (Vanille)', 'Cremeschnitte', 'Tiramisu'];
23
+
24
+ const options = (labels: string[]): string => labels.map((label: string): string => `<option>${label}</option>`).join('');
25
+
26
+ const mount = (selectContent: string, filterMarkup: string = '<input type="text" class="t3js-formengine-multiselect-filter-textfield">'): void => {
27
+ root = document.createElement('div');
28
+ root.innerHTML = `
29
+ <div class="form-wizards-wrap">
30
+ ${filterMarkup}
31
+ <select multiple>${selectContent}</select>
32
+ </div>`;
33
+ document.body.appendChild(root);
34
+ new SelectBoxFilter(root.querySelector('select[multiple]'));
35
+ };
36
+
37
+ afterEach((): void => {
38
+ root.remove();
39
+ });
40
+
41
+ const visibleOptions = (): string[] => Array
42
+ .from(root.querySelectorAll('select[multiple] option'))
43
+ .filter((option: HTMLOptionElement): boolean => !option.hidden)
44
+ .map((option: HTMLOptionElement): string => option.textContent);
45
+
46
+ const filterBy = (value: string): void => {
47
+ filterField.value = value;
48
+ filterField.dispatchEvent(new Event('input', { bubbles: true }));
49
+ };
50
+
51
+ describe('text field', () => {
52
+ beforeEach((): void => {
53
+ mount(options(optionLabels));
54
+ filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
55
+ });
56
+
57
+ it('shows all options for an empty filter', (): void => {
58
+ filterBy('');
59
+ expect(visibleOptions()).to.eql(optionLabels);
60
+ });
61
+
62
+ it('matches case insensitively', (): void => {
63
+ filterBy('MÜLLER');
64
+ expect(visibleOptions()).to.eql(['Müller']);
65
+ });
66
+
67
+ it('matches a plain substring anywhere in the label', (): void => {
68
+ filterBy('schnitte');
69
+ expect(visibleOptions()).to.eql(['Cremeschnitte']);
70
+ });
71
+
72
+ it('matches labels with diacritics when typing without them', (): void => {
73
+ filterBy('creme');
74
+ expect(visibleOptions()).to.eql(['Crème brûlée', 'Cremeschnitte']);
75
+ });
76
+
77
+ it('matches labels without diacritics when typing with them', (): void => {
78
+ filterBy('crèm');
79
+ expect(visibleOptions()).to.eql(['Crème brûlée', 'Cremeschnitte']);
80
+ });
81
+
82
+ it('treats regular expression meta characters as literal text', (): void => {
83
+ filterBy('(Vanille)');
84
+ expect(visibleOptions()).to.eql(['Sirup (Vanille)']);
85
+ });
86
+
87
+ it('hides everything when nothing matches', (): void => {
88
+ filterBy('.*');
89
+ expect(visibleOptions()).to.eql([]);
90
+ });
91
+ });
92
+
93
+ describe('Latin letters that do not decompose', () => {
94
+ const labels: string[] = ['Straße', 'Müller', 'Ærø', 'Łódź'];
95
+
96
+ beforeEach((): void => {
97
+ mount(options(labels));
98
+ filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
99
+ });
100
+
101
+ it('finds the sharp s when it is typed as a double s', (): void => {
102
+ filterBy('strasse');
103
+ expect(visibleOptions()).to.eql(['Straße']);
104
+ });
105
+
106
+ it('finds the sharp s when it is typed as written', (): void => {
107
+ filterBy('straße');
108
+ expect(visibleOptions()).to.eql(['Straße']);
109
+ });
110
+
111
+ it('finds the slashed o and the ligature typed as ASCII', (): void => {
112
+ filterBy('aero');
113
+ expect(visibleOptions()).to.eql(['Ærø']);
114
+ });
115
+
116
+ it('finds the stroked l typed as a plain l', (): void => {
117
+ filterBy('lodz');
118
+ expect(visibleOptions()).to.eql(['Łódź']);
119
+ });
120
+
121
+ it('does not resolve an umlaut to its transcription', (): void => {
122
+ filterBy('mueller');
123
+ expect(visibleOptions()).to.eql([]);
124
+ });
125
+ });
126
+
127
+ describe('characters the editor cannot see in the label', () => {
128
+ const nbsp = 'Cr\u00e8me\u00a0br\u00fbl\u00e9e';
129
+ const softHyphen = 'Do\u00adnau';
130
+ const zeroWidth = 'Ti\u200bramisu';
131
+ const labels: string[] = [nbsp, softHyphen, zeroWidth];
132
+
133
+ beforeEach((): void => {
134
+ mount(options(labels));
135
+ filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
136
+ });
137
+
138
+ it('matches across a no-break space when a plain space is typed', (): void => {
139
+ filterBy('creme brulee');
140
+ expect(visibleOptions()).to.eql([nbsp]);
141
+ });
142
+
143
+ it('matches across a soft hyphen', (): void => {
144
+ filterBy('donau');
145
+ expect(visibleOptions()).to.eql([softHyphen]);
146
+ });
147
+
148
+ it('matches across a zero width space', (): void => {
149
+ filterBy('tiramisu');
150
+ expect(visibleOptions()).to.eql([zeroWidth]);
151
+ });
152
+ });
153
+
154
+ describe('scripts whose combining marks carry meaning', () => {
155
+ // Thai vowel signs are nonspacing marks, but they distinguish syllables rather
156
+ // than accent them. Removing them would fold these three labels into one and
157
+ // the filter would stop narrowing anything for Thai.
158
+ const labels: string[] = ['กิน', 'กีฬา', 'กุญแจ'];
159
+
160
+ beforeEach((): void => {
161
+ mount(options(labels));
162
+ filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
163
+ });
164
+
165
+ it('keeps Thai vowel signs, so the filter still narrows', (): void => {
166
+ filterBy('กิ');
167
+ expect(visibleOptions()).to.eql(['กิน']);
168
+ });
169
+ });
170
+
171
+ describe('predefined filter items', () => {
172
+ beforeEach((): void => {
173
+ mount(
174
+ options(optionLabels),
175
+ `<select class="t3js-formengine-multiselect-filter-dropdown">
176
+ <option value=""></option>
177
+ <option value="creme">creme</option>
178
+ </select>`
179
+ );
180
+ filterField = root.querySelector('.t3js-formengine-multiselect-filter-dropdown');
181
+ });
182
+
183
+ it('applies a predefined filter value on change', (): void => {
184
+ filterField.value = 'creme';
185
+ filterField.dispatchEvent(new Event('change', { bubbles: true }));
186
+ expect(visibleOptions()).to.eql(['Crème brûlée', 'Cremeschnitte']);
187
+ });
188
+ });
189
+
190
+ describe('option groups', () => {
191
+ beforeEach((): void => {
192
+ mount(`
193
+ <optgroup label="Desserts">${options(['Crème brûlée', 'Tiramisu'])}</optgroup>
194
+ <optgroup label="Namen">${options(['Müller'])}</optgroup>`);
195
+ filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
196
+ });
197
+
198
+ const visibleGroups = (): string[] => Array
199
+ .from(root.querySelectorAll('optgroup'))
200
+ .filter((group: HTMLOptGroupElement): boolean => !group.hidden)
201
+ .map((group: HTMLOptGroupElement): string => group.label);
202
+
203
+ it('hides a group once all of its options are filtered away', (): void => {
204
+ filterBy('creme');
205
+ expect(visibleOptions()).to.eql(['Crème brûlée']);
206
+ expect(visibleGroups()).to.eql(['Desserts']);
207
+ });
208
+
209
+ it('shows every group again for an empty filter', (): void => {
210
+ filterBy('creme');
211
+ filterBy('');
212
+ expect(visibleGroups()).to.eql(['Desserts', 'Namen']);
213
+ });
214
+ });
215
+ });
@@ -0,0 +1,121 @@
1
+ /*
2
+ * This file is part of the TYPO3 CMS project.
3
+ *
4
+ * It is free software; you can redistribute it and/or modify it under
5
+ * the terms of the GNU General Public License, either version 2
6
+ * of the License, or any later version.
7
+ *
8
+ * For the full copyright and license information, please read the
9
+ * LICENSE.txt file that was distributed with this source code.
10
+ *
11
+ * The TYPO3 project - inspiring people to share!
12
+ */
13
+
14
+ // Imported for its side effect, the module registers the custom element used below
15
+ import '@typo3/backend/form-engine/element/select-tree.js';
16
+ import type { SelectTree } from '@typo3/backend/form-engine/element/select-tree.js';
17
+ import type { TreeNodeInterface } from '@typo3/backend/tree/tree-node.js';
18
+ import { expect } from '@open-wc/testing';
19
+ import type { } from 'mocha';
20
+
21
+ describe('@typo3/backend/form-engine/element/select-tree', () => {
22
+ let tree: SelectTree;
23
+
24
+ const createNode = (identifier: string, name: string, parents: string[]): TreeNodeInterface => {
25
+ return <TreeNodeInterface><unknown>{
26
+ identifier,
27
+ name,
28
+ parents,
29
+ depth: parents.length,
30
+ __parents: parents,
31
+ __treeIdentifier: identifier,
32
+ __processed: true,
33
+ __hidden: false,
34
+ __expanded: false,
35
+ };
36
+ };
37
+
38
+ beforeEach((): void => {
39
+ tree = <SelectTree>document.createElement('typo3-backend-form-selecttree');
40
+ tree.nodes = [
41
+ createNode('root', 'Root', []),
42
+ createNode('vanilla', 'Vanille', ['root']),
43
+ createNode('syrup', 'Sirup (Vanille)', ['root']),
44
+ createNode('cocoa', 'Kakao', ['root', 'syrup']),
45
+ ];
46
+ });
47
+
48
+ const visibleNodes = (): string[] => tree.nodes
49
+ .filter((node: TreeNodeInterface): boolean => node.identifier !== 'root' && !node.__hidden)
50
+ .map((node: TreeNodeInterface): string => node.name);
51
+
52
+ it('matches case insensitively', (): void => {
53
+ tree.filter('VANILLE');
54
+ expect(visibleNodes()).to.eql(['Vanille', 'Sirup (Vanille)', 'Kakao']);
55
+ });
56
+
57
+ it('treats regular expression meta characters as literal text', (): void => {
58
+ tree.filter('(Vanille)');
59
+ expect(visibleNodes()).to.eql(['Sirup (Vanille)', 'Kakao']);
60
+ });
61
+
62
+ it('does not throw on an unbalanced meta character', (): void => {
63
+ expect((): void => tree.filter('(')).to.not.throw();
64
+ expect(visibleNodes()).to.eql(['Sirup (Vanille)', 'Kakao']);
65
+ });
66
+
67
+ it('shows all nodes for an empty search term', (): void => {
68
+ tree.filter('');
69
+ expect(visibleNodes()).to.eql(['Vanille', 'Sirup (Vanille)', 'Kakao']);
70
+ });
71
+
72
+ it('hides everything when nothing matches', (): void => {
73
+ tree.filter('Erdbeere');
74
+ expect(visibleNodes()).to.eql([]);
75
+ });
76
+
77
+ describe('folding of the search term and the node names', () => {
78
+ beforeEach((): void => {
79
+ tree.nodes = [
80
+ createNode('root', 'Root', []),
81
+ createNode('creme', 'Crème brûlée', ['root']),
82
+ createNode('cremeschnitte', 'Cremeschnitte', ['root']),
83
+ createNode('strasse', 'Straße', ['root']),
84
+ ];
85
+ });
86
+
87
+ it('matches a name with diacritics when they are not typed', (): void => {
88
+ tree.filter('creme');
89
+ expect(visibleNodes()).to.eql(['Crème brûlée', 'Cremeschnitte']);
90
+ });
91
+
92
+ it('matches a name without diacritics when they are typed', (): void => {
93
+ tree.filter('crèm');
94
+ expect(visibleNodes()).to.eql(['Crème brûlée', 'Cremeschnitte']);
95
+ });
96
+
97
+ it('matches a Latin letter that does not decompose by its ASCII spelling', (): void => {
98
+ tree.filter('strasse');
99
+ expect(visibleNodes()).to.eql(['Straße']);
100
+ });
101
+
102
+ it('ignores a character the reader cannot see in the name', (): void => {
103
+ tree.nodes = [
104
+ createNode('root', 'Root', []),
105
+ createNode('creme', 'Creme\u00adschnitte', ['root']),
106
+ ];
107
+ tree.filter('cremeschnitte');
108
+ expect(visibleNodes()).to.eql(['Creme\u00adschnitte']);
109
+ });
110
+
111
+ it('shows the parents of a node matched without its diacritics', (): void => {
112
+ tree.nodes = [
113
+ createNode('root', 'Root', []),
114
+ createNode('sweets', 'Süßwaren', ['root']),
115
+ createNode('creme', 'Crème brûlée', ['root', 'sweets']),
116
+ ];
117
+ tree.filter('creme');
118
+ expect(visibleNodes()).to.eql(['Süßwaren', 'Crème brûlée']);
119
+ });
120
+ });
121
+ });
@@ -0,0 +1,91 @@
1
+ /*
2
+ * This file is part of the TYPO3 CMS project.
3
+ *
4
+ * It is free software; you can redistribute it and/or modify it under
5
+ * the terms of the GNU General Public License, either version 2
6
+ * of the License, or any later version.
7
+ *
8
+ * For the full copyright and license information, please read the
9
+ * LICENSE.txt file that was distributed with this source code.
10
+ *
11
+ * The TYPO3 project - inspiring people to share!
12
+ */
13
+
14
+ import { html } from 'lit';
15
+ import { expect } from '@open-wc/testing';
16
+ import Modal, { type ModalElement } from '@typo3/backend/modal.js';
17
+ import type { } from 'mocha';
18
+
19
+ describe('@typo3/backend/modal', () => {
20
+ let modal: ModalElement;
21
+ const nativeRequestAnimationFrame = window.requestAnimationFrame;
22
+
23
+ const shown = (element: ModalElement): Promise<void> => new Promise(resolve => {
24
+ element.addEventListener('typo3-modal-shown', () => resolve(), { once: true });
25
+ });
26
+
27
+ before(() => {
28
+ // The test page runs in the background, animation frames never fire there
29
+ window.requestAnimationFrame = (callback: FrameRequestCallback): number => window.setTimeout(() => callback(performance.now()), 0);
30
+ });
31
+
32
+ after(() => {
33
+ window.requestAnimationFrame = nativeRequestAnimationFrame;
34
+ });
35
+
36
+ afterEach(() => {
37
+ // The dialog close event does not fire in the background either
38
+ modal?.hideModal();
39
+ modal?.remove();
40
+ });
41
+
42
+ it('binds a footer button to the form given by its configuration', async () => {
43
+ modal = Modal.advanced({
44
+ content: html`<form id="modal-test-form"><input name="username"><input name="password"></form>`,
45
+ buttons: [{ text: 'ok', btnClass: 'btn-primary', form: 'modal-test-form' }],
46
+ });
47
+ await shown(modal);
48
+
49
+ const button = modal.querySelector<HTMLButtonElement>('.modal-footer button');
50
+ expect(button.type).to.equal('submit');
51
+ expect(button.form?.id).to.equal('modal-test-form');
52
+ });
53
+
54
+ it('keeps the focus on an autofocus field instead of the active button', async () => {
55
+ modal = Modal.advanced({
56
+ content: html`<form><input name="password" autofocus></form>`,
57
+ buttons: [{ text: 'ok', btnClass: 'btn-primary', active: true }],
58
+ });
59
+ await shown(modal);
60
+
61
+ expect(document.activeElement.matches('input[name="password"]')).to.be.true;
62
+ });
63
+
64
+ it('focuses an autofocus field of ajax content loaded after the modal was shown', async () => {
65
+ const nativeFetch = window.fetch;
66
+ window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
67
+ await shown(modal);
68
+ return nativeFetch(input, init);
69
+ };
70
+ modal = Modal.advanced({
71
+ type: Modal.types.ajax,
72
+ content: '/Build/Sources/TypeScript/backend/tests/fixtures/modal-autofocus.html',
73
+ buttons: [{ text: 'ok', btnClass: 'btn-primary', active: true }],
74
+ });
75
+ const loaded = new Promise<void>(resolve => modal.addEventListener('modal-loaded', () => resolve(), { once: true }));
76
+ await loaded;
77
+ window.fetch = nativeFetch;
78
+
79
+ expect(document.activeElement.matches('input[name="filter"]')).to.be.true;
80
+ });
81
+
82
+ it('focuses the active button when the body has no autofocus field', async () => {
83
+ modal = Modal.advanced({
84
+ content: html`<form><input name="password"></form>`,
85
+ buttons: [{ text: 'ok', btnClass: 'btn-primary', active: true }],
86
+ });
87
+ await shown(modal);
88
+
89
+ expect(document.activeElement.matches('.modal-footer button')).to.be.true;
90
+ });
91
+ });