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.
- package/.last-synced-tag +1 -1
- package/README-TYPO3.md +1 -1
- package/README.md +1 -1
- package/backend/column-selector-button.ts +5 -2
- package/backend/drag-uploader.ts +78 -86
- package/backend/element/contextual-record-edit-trigger.ts +9 -1
- package/backend/form-engine/element/extra/select-box-filter.ts +8 -5
- package/backend/form-engine/element/online-media-form-element.ts +3 -3
- package/backend/form-engine/element/select-tree.ts +16 -2
- package/backend/form-engine/field-control/add-record.ts +27 -1
- package/backend/form-engine.ts +88 -13
- package/backend/layout-module/column-restriction.ts +61 -0
- package/backend/layout-module/drag-drop.ts +12 -0
- package/backend/layout-module/paste.ts +7 -0
- package/backend/localization/localization-wizard.ts +3 -0
- package/backend/localization/steps/handler-selection-step.ts +12 -17
- package/backend/localization/steps/mode-step.ts +9 -14
- package/backend/localization/steps/source-language-step.ts +8 -13
- package/backend/localization/steps/target-language-step.ts +12 -17
- package/backend/login-refresh.ts +1 -9
- package/backend/modal.ts +162 -6
- package/backend/notification.ts +90 -6
- package/backend/online-media.ts +1 -3
- package/backend/record-download-button.ts +4 -5
- package/backend/recordlist.ts +5 -0
- package/backend/tests/fixtures/modal-autofocus.html +1 -0
- package/backend/tests/form-engine/element/extra/select-box-filter-test.ts +215 -0
- package/backend/tests/form-engine/element/select-tree-test.ts +121 -0
- package/backend/tests/modal-test.ts +91 -0
- package/backend/tests/notification-test.ts +176 -0
- package/backend/tree/page-tree-element.ts +8 -1
- package/backend/wizard/steps/finisher-step.ts +2 -2
- package/backend/wizard/wizard.ts +11 -4
- package/core/ajax/ajax-request.ts +18 -0
- package/core/tests/ajax/ajax-request-test.ts +1 -1
- package/core/tests/utility/text-normalizer-test.ts +138 -0
- package/core/utility/text-normalizer.ts +98 -0
- package/dashboard/dashboard.ts +3 -8
- package/filelist/file-list-rename-handler.ts +3 -6
- package/filelist/file-list-replace-handler.ts +3 -6
- package/filelist/file-list.ts +3 -4
- package/form/backend/form-editor/modals-component.ts +114 -5
- package/form/backend/form-editor/view-model.ts +1 -1
- package/form/backend/form-wizard/steps/settings-step.ts +6 -3
- package/package.json +2 -2
- package/rte-ckeditor/legacy/ckeditor5-bundle.ts +0 -1
- package/rte-ckeditor/plugin/typo3-link.ts +7 -9
- package/rte-ckeditor/rte-link-browser.ts +1 -1
- package/scheduler/scheduler-add-group.ts +4 -12
- package/workspaces/backend.ts +7 -2
- package/workspaces/preview.ts +39 -0
- package/workspaces/renderable/comment-view.ts +4 -2
- package/workspaces/renderable/record-table.ts +134 -80
- package/workspaces/workspaces.ts +65 -12
package/backend/notification.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
this.
|
|
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(
|
|
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 {
|
package/backend/online-media.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/backend/recordlist.ts
CHANGED
|
@@ -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
|
+
});
|