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
|
@@ -106,6 +106,124 @@ describe('@typo3/backend/notification:', () => {
|
|
|
106
106
|
}
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
describe('auto dismissal', () => {
|
|
110
|
+
async function showNotification(): Promise<{ element: LitElement, isCleared: () => boolean }> {
|
|
111
|
+
Notification.success('Title', 'Message', 1);
|
|
112
|
+
const element = document.querySelector('#alert-container typo3-notification-message:last-child') as LitElement;
|
|
113
|
+
await element.updateComplete;
|
|
114
|
+
let cleared = false;
|
|
115
|
+
element.addEventListener('typo3-notification-clear', (): void => {
|
|
116
|
+
cleared = true;
|
|
117
|
+
});
|
|
118
|
+
// Notifications appear with a delay, the countdown starts afterwards
|
|
119
|
+
await clock.tickAsync(200);
|
|
120
|
+
return { element, isCleared: (): boolean => cleared };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it('pauses while the notification is pointed at', async () => {
|
|
124
|
+
const { element, isCleared } = await showNotification();
|
|
125
|
+
|
|
126
|
+
element.dispatchEvent(new MouseEvent('mouseenter'));
|
|
127
|
+
await clock.tickAsync(5000);
|
|
128
|
+
expect(isCleared()).to.be.false;
|
|
129
|
+
|
|
130
|
+
element.dispatchEvent(new MouseEvent('mouseleave'));
|
|
131
|
+
await clock.tickAsync(1100);
|
|
132
|
+
expect(isCleared()).to.be.true;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('pauses while the notification has the focus', async () => {
|
|
136
|
+
const { element, isCleared } = await showNotification();
|
|
137
|
+
|
|
138
|
+
element.dispatchEvent(new FocusEvent('focusin'));
|
|
139
|
+
await clock.tickAsync(5000);
|
|
140
|
+
expect(isCleared()).to.be.false;
|
|
141
|
+
|
|
142
|
+
element.dispatchEvent(new FocusEvent('focusout'));
|
|
143
|
+
await clock.tickAsync(1100);
|
|
144
|
+
expect(isCleared()).to.be.true;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('continues with the time that was left', async () => {
|
|
148
|
+
const { element, isCleared } = await showNotification();
|
|
149
|
+
|
|
150
|
+
await clock.tickAsync(600);
|
|
151
|
+
element.dispatchEvent(new MouseEvent('mouseenter'));
|
|
152
|
+
await clock.tickAsync(5000);
|
|
153
|
+
element.dispatchEvent(new MouseEvent('mouseleave'));
|
|
154
|
+
|
|
155
|
+
await clock.tickAsync(300);
|
|
156
|
+
expect(isCleared()).to.be.false;
|
|
157
|
+
await clock.tickAsync(200);
|
|
158
|
+
expect(isCleared()).to.be.true;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('stays paused when the pointer leaves while the focus is still inside', async () => {
|
|
162
|
+
const { element, isCleared } = await showNotification();
|
|
163
|
+
|
|
164
|
+
element.dispatchEvent(new FocusEvent('focusin'));
|
|
165
|
+
element.dispatchEvent(new MouseEvent('mouseenter'));
|
|
166
|
+
element.dispatchEvent(new MouseEvent('mouseleave'));
|
|
167
|
+
await clock.tickAsync(5000);
|
|
168
|
+
expect(isCleared()).to.be.false;
|
|
169
|
+
|
|
170
|
+
element.dispatchEvent(new FocusEvent('focusout'));
|
|
171
|
+
await clock.tickAsync(1100);
|
|
172
|
+
expect(isCleared()).to.be.true;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('stays paused when the pointer rests on the notification before it appeared', async () => {
|
|
176
|
+
Notification.success('Title', 'Message', 1);
|
|
177
|
+
const element = document.querySelector('#alert-container typo3-notification-message:last-child') as LitElement;
|
|
178
|
+
await element.updateComplete;
|
|
179
|
+
let cleared = false;
|
|
180
|
+
element.addEventListener('typo3-notification-clear', (): void => {
|
|
181
|
+
cleared = true;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
element.dispatchEvent(new MouseEvent('mouseenter'));
|
|
185
|
+
await clock.tickAsync(5000);
|
|
186
|
+
expect(cleared).to.be.false;
|
|
187
|
+
|
|
188
|
+
element.dispatchEvent(new MouseEvent('mouseleave'));
|
|
189
|
+
await clock.tickAsync(1100);
|
|
190
|
+
expect(cleared).to.be.true;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('is not cleared by the countdown while an action is executing', async () => {
|
|
194
|
+
let finishAction: () => void;
|
|
195
|
+
Notification.success('Title', 'Message', 1, [
|
|
196
|
+
{
|
|
197
|
+
label: 'Undo',
|
|
198
|
+
action: new DeferredAction((): Promise<void> => new Promise((resolve): void => {
|
|
199
|
+
finishAction = resolve;
|
|
200
|
+
})),
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
const element = document.querySelector('#alert-container typo3-notification-message:last-child') as LitElement;
|
|
204
|
+
await element.updateComplete;
|
|
205
|
+
let cleared = false;
|
|
206
|
+
element.addEventListener('typo3-notification-clear', (): void => {
|
|
207
|
+
cleared = true;
|
|
208
|
+
});
|
|
209
|
+
await clock.tickAsync(200);
|
|
210
|
+
|
|
211
|
+
(element.querySelector('.alert-actions a') as HTMLAnchorElement).click();
|
|
212
|
+
await clock.tickAsync(5000);
|
|
213
|
+
expect(cleared).to.be.false;
|
|
214
|
+
|
|
215
|
+
finishAction();
|
|
216
|
+
await clock.tickAsync(0);
|
|
217
|
+
expect(cleared).to.be.true;
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('announces notifications to assistive technology', async () => {
|
|
222
|
+
Notification.info('Info message', 'Some text', 1);
|
|
223
|
+
await (document.querySelector('#alert-container typo3-notification-message:last-child') as LitElement).updateComplete;
|
|
224
|
+
expect(document.querySelector('#alert-container .alert-list').getAttribute('aria-live')).to.equal('polite');
|
|
225
|
+
});
|
|
226
|
+
|
|
109
227
|
it('can render action buttons', async () => {
|
|
110
228
|
Notification.info(
|
|
111
229
|
'Info message',
|
|
@@ -158,4 +276,62 @@ describe('@typo3/backend/notification:', () => {
|
|
|
158
276
|
await (document.querySelector('#alert-container typo3-notification-message:last-child') as LitElement).updateComplete;
|
|
159
277
|
expect(called).to.be.true;
|
|
160
278
|
});
|
|
279
|
+
|
|
280
|
+
it('deferred action is called with its link', async () => {
|
|
281
|
+
let called = false;
|
|
282
|
+
Notification.info('Info message', 'Some text', 0, [
|
|
283
|
+
{
|
|
284
|
+
label: 'My deferred action',
|
|
285
|
+
action: new DeferredAction(async (): Promise<void> => {
|
|
286
|
+
called = true;
|
|
287
|
+
}),
|
|
288
|
+
},
|
|
289
|
+
]);
|
|
290
|
+
const element = document.querySelector('#alert-container typo3-notification-message:last-child') as LitElement;
|
|
291
|
+
await element.updateComplete;
|
|
292
|
+
|
|
293
|
+
(element.querySelector('.alert-actions a') as HTMLAnchorElement).click();
|
|
294
|
+
await clock.tickAsync(0);
|
|
295
|
+
|
|
296
|
+
expect(called).to.be.true;
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('executes an action only once while it is executing', async () => {
|
|
300
|
+
let calls = 0;
|
|
301
|
+
let finishAction: () => void;
|
|
302
|
+
Notification.info('Info message', 'Some text', 0, [
|
|
303
|
+
{
|
|
304
|
+
label: 'Deferred action',
|
|
305
|
+
action: new DeferredAction((): Promise<void> => {
|
|
306
|
+
calls++;
|
|
307
|
+
return new Promise((resolve): void => {
|
|
308
|
+
finishAction = resolve;
|
|
309
|
+
});
|
|
310
|
+
}),
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
label: 'Other action',
|
|
314
|
+
action: new ImmediateAction((): void => {
|
|
315
|
+
calls++;
|
|
316
|
+
}),
|
|
317
|
+
},
|
|
318
|
+
]);
|
|
319
|
+
const element = document.querySelector('#alert-container typo3-notification-message:last-child') as LitElement;
|
|
320
|
+
await element.updateComplete;
|
|
321
|
+
const [first, second] = Array.from(element.querySelectorAll('.alert-actions a')) as HTMLAnchorElement[];
|
|
322
|
+
|
|
323
|
+
first.click();
|
|
324
|
+
await clock.tickAsync(0);
|
|
325
|
+
// Keyboard activation still reaches a link which ignores the pointer
|
|
326
|
+
first.click();
|
|
327
|
+
second.click();
|
|
328
|
+
await clock.tickAsync(0);
|
|
329
|
+
expect(calls).to.equal(1);
|
|
330
|
+
expect(first.getAttribute('aria-disabled')).to.equal('true');
|
|
331
|
+
expect(second.getAttribute('aria-disabled')).to.equal('true');
|
|
332
|
+
|
|
333
|
+
finishAction();
|
|
334
|
+
await clock.tickAsync(0);
|
|
335
|
+
expect(calls).to.equal(1);
|
|
336
|
+
});
|
|
161
337
|
});
|
|
@@ -110,7 +110,9 @@ export class EditablePageTree extends PageTree {
|
|
|
110
110
|
'&data[pages][' + data.node.identifier + '][title]=' + encodeURIComponent(newData.title) +
|
|
111
111
|
'&data[pages][' + data.node.identifier + '][doktype]=' + encodeURIComponent(newData.doktype);
|
|
112
112
|
} else if (data.command === TreeNodeCommandEnum.EDIT) {
|
|
113
|
-
|
|
113
|
+
// The tree may show nav_title instead of title, so write back to the field that is displayed
|
|
114
|
+
const nameSourceField = data.node.nameSourceField || 'title';
|
|
115
|
+
params = '&data[pages][' + data.node.identifier + '][' + nameSourceField + ']=' + encodeURIComponent(data.title);
|
|
114
116
|
} else if (data.command === TreeNodeCommandEnum.DELETE) {
|
|
115
117
|
// @todo currently it's "If uid of deleted record (data.uid) is still selected, randomly select the first node"
|
|
116
118
|
const moduleStateStorage = ModuleStateStorage.current('web');
|
|
@@ -245,6 +247,11 @@ export class EditablePageTree extends PageTree {
|
|
|
245
247
|
command: TreeNodeCommandEnum.MOVE
|
|
246
248
|
};
|
|
247
249
|
|
|
250
|
+
if (!this.settings.displayCopyMoveConfirmation) {
|
|
251
|
+
this.sendChangeCommand(options);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
248
255
|
let modalText = '';
|
|
249
256
|
const languageArguments = [node.name, target.name] as const;
|
|
250
257
|
switch(position) {
|
|
@@ -33,9 +33,9 @@ export class FinisherStep implements WizardStepInterface {
|
|
|
33
33
|
readonly autoAdvance = false;
|
|
34
34
|
|
|
35
35
|
public resetButtonTitle: string = null;
|
|
36
|
+
public hasError = false;
|
|
36
37
|
|
|
37
38
|
private finisherInstance: FinisherInterface | null = null;
|
|
38
|
-
private hasError = false;
|
|
39
39
|
private readonly task: Task<[SubmissionServiceInterface], FinisherResult>;
|
|
40
40
|
|
|
41
41
|
constructor(private readonly wizard: Wizard, private readonly finisher: SubmissionServiceInterface) {
|
|
@@ -55,7 +55,7 @@ export class FinisherStep implements WizardStepInterface {
|
|
|
55
55
|
public isComplete(): boolean {
|
|
56
56
|
// Finisher step is complete when the task has finished (success or error)
|
|
57
57
|
// This allows the user to close the wizard even when there's an error
|
|
58
|
-
return this.task.status === TaskStatus.COMPLETE;
|
|
58
|
+
return this.task.status === TaskStatus.COMPLETE || this.task.status === TaskStatus.ERROR;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
public async beforeAdvance(): Promise<void> {
|
package/backend/wizard/wizard.ts
CHANGED
|
@@ -332,13 +332,20 @@ export class Wizard extends LitElement {
|
|
|
332
332
|
private renderNextButtons(): TemplateResult {
|
|
333
333
|
const isComplete = this.currentStep?.isComplete();
|
|
334
334
|
let buttonLabel: string;
|
|
335
|
+
let buttonIcon: string = 'actions-arrow-end-alt';
|
|
335
336
|
let resetButtonHtml: TemplateResult | typeof nothing = nothing;
|
|
336
337
|
|
|
337
338
|
if (this.currentStep?.key === 'finisher') {
|
|
338
|
-
buttonLabel = wizardLabels.get('wizard.buttons.finish');
|
|
339
|
-
|
|
340
339
|
const finisherStep: FinisherStep = this.currentStep as FinisherStep;
|
|
341
|
-
if (finisherStep
|
|
340
|
+
if (finisherStep.hasError) {
|
|
341
|
+
// On failure the primary action only closes the wizard
|
|
342
|
+
buttonLabel = wizardLabels.get('wizard.buttons.close');
|
|
343
|
+
buttonIcon = 'actions-close';
|
|
344
|
+
} else {
|
|
345
|
+
buttonLabel = wizardLabels.get('wizard.buttons.finish');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!finisherStep.hasError && finisherStep?.resetButtonTitle) {
|
|
342
349
|
resetButtonHtml = html`
|
|
343
350
|
<button type="button" class="${classMap({ 'btn': true, 'btn-secondary': true })}"
|
|
344
351
|
@click="${this.handleRestart}"
|
|
@@ -361,7 +368,7 @@ export class Wizard extends LitElement {
|
|
|
361
368
|
@click="${this.handleNext}"
|
|
362
369
|
>
|
|
363
370
|
${buttonLabel}
|
|
364
|
-
<typo3-backend-icon identifier="
|
|
371
|
+
<typo3-backend-icon identifier="${buttonIcon}" bidi size="small"></typo3-backend-icon>
|
|
365
372
|
</button>
|
|
366
373
|
</div>
|
|
367
374
|
`;
|
|
@@ -117,6 +117,24 @@ class AjaxRequest {
|
|
|
117
117
|
return new AjaxResponse(response);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Executes a (by default uncached) PATCH request
|
|
122
|
+
*
|
|
123
|
+
* @param {string | FormData | GenericKeyValue} data
|
|
124
|
+
* @param {RequestInit} init
|
|
125
|
+
* @return {Promise<Response>}
|
|
126
|
+
*/
|
|
127
|
+
public async patch(data: string | FormData | GenericKeyValue, init: RequestInit = {}): Promise<AjaxResponse> {
|
|
128
|
+
const localDefaultOptions: RequestInit = {
|
|
129
|
+
body: typeof data === 'string' || data instanceof FormData ? data : InputTransformer.byHeader(data, init?.headers),
|
|
130
|
+
cache: 'no-cache',
|
|
131
|
+
method: 'PATCH',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const response = await this.send({ ...localDefaultOptions, ...init });
|
|
135
|
+
return new AjaxResponse(response);
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
/**
|
|
121
139
|
* Executes a regular DELETE request
|
|
122
140
|
*
|
|
@@ -50,7 +50,7 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
|
|
|
50
50
|
expect(fetchStub).calledWithMatch(new Request('https://example.com/', { method: 'POST', body: '' }));
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
for (const requestMethod of ['POST', 'PUT', 'DELETE']) {
|
|
53
|
+
for (const requestMethod of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
|
54
54
|
describe(`send a ${requestMethod} request`, (): void => {
|
|
55
55
|
function* requestDataProvider(): any {
|
|
56
56
|
yield [
|
|
@@ -0,0 +1,138 @@
|
|
|
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 TextNormalizer from '@typo3/core/utility/text-normalizer.js';
|
|
15
|
+
import { expect } from '@open-wc/testing';
|
|
16
|
+
import type { } from 'mocha';
|
|
17
|
+
|
|
18
|
+
describe('@typo3/core/utility/text-normalizer', () => {
|
|
19
|
+
describe('foldCaseAndDiacritics', () => {
|
|
20
|
+
it('returns an empty string unchanged', (): void => {
|
|
21
|
+
expect(TextNormalizer.foldCaseAndDiacritics('')).to.equal('');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('lowercases the value', (): void => {
|
|
25
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Cremeschnitte')).to.equal('cremeschnitte');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('removes Latin diacritics', (): void => {
|
|
29
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Crème brûlée')).to.equal('creme brulee');
|
|
30
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Müller')).to.equal('muller');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('removes Greek and Cyrillic diacritics', (): void => {
|
|
34
|
+
expect(TextNormalizer.foldCaseAndDiacritics('ΆΘΗΝΑ')).to.equal('αθηνα');
|
|
35
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Ёлка')).to.equal('елка');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('removes every Vietnamese tone mark', (): void => {
|
|
39
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Tiếng Việt')).to.equal('tieng viet');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('folds a value that is already decomposed to the same result', (): void => {
|
|
43
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Crème'.normalize('NFD'))).to.equal(TextNormalizer.foldCaseAndDiacritics('Crème'.normalize('NFC')));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A locale aware mapping folds this to a dotless "ı" on a Turkish browser.
|
|
47
|
+
it('folds the capital i to a plain lowercase i', (): void => {
|
|
48
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Import')).to.equal('import');
|
|
49
|
+
expect(TextNormalizer.foldCaseAndDiacritics('İstanbul')).to.equal('istanbul');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('spells out the Latin letters that do not decompose', (): void => {
|
|
53
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Straße')).to.equal('strasse');
|
|
54
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Ærø')).to.equal('aero');
|
|
55
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Łódź')).to.equal('lodz');
|
|
56
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Þórður')).to.equal('thordur');
|
|
57
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Đà Nẵng')).to.equal('da nang');
|
|
58
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Ħamrun')).to.equal('hamrun');
|
|
59
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Cœur')).to.equal('coeur');
|
|
60
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Kız')).to.equal('kiz');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('folds an alphabet consistently rather than half of it', (): void => {
|
|
64
|
+
// Danish: "å" decomposes and "æ"/"ø" do not, so without the expansions one
|
|
65
|
+
// half of the alphabet would fold and the other half would not.
|
|
66
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Åse')).to.equal('ase');
|
|
67
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Æble')).to.equal('aeble');
|
|
68
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Øst')).to.equal('ost');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('leaves the language dependent transcriptions alone', (): void => {
|
|
72
|
+
// "ü" decomposes to "u", so the German transcription to "ue" is not applied
|
|
73
|
+
// on top of it -- a value cannot fold to both.
|
|
74
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Müller')).to.equal('muller');
|
|
75
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Müller')).to.not.equal('mueller');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('removes the Hebrew points', (): void => {
|
|
79
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\u05e9\u05c1\u05b8\u05dc\u05d5\u05b9\u05dd'))
|
|
80
|
+
.to.equal(TextNormalizer.foldCaseAndDiacritics('\u05e9\u05dc\u05d5\u05dd'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('removes the Arabic harakat', (): void => {
|
|
84
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\u0645\u064e\u062f\u0652\u0631\u064e\u0633\u064e\u0629'))
|
|
85
|
+
.to.equal(TextNormalizer.foldCaseAndDiacritics('\u0645\u062f\u0631\u0633\u0629'));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('folds the Arabic alef variants onto the plain alef', (): void => {
|
|
89
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\u0623\u062d\u0645\u062f'))
|
|
90
|
+
.to.equal(TextNormalizer.foldCaseAndDiacritics('\u0627\u062d\u0645\u062f'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps the marks a script writes obligatorily', (): void => {
|
|
94
|
+
// Thai vowel signs, Devanagari matras and the Japanese dakuten separate
|
|
95
|
+
// words rather than accent them, so folding must leave them alone.
|
|
96
|
+
const distinct = (values: string[]): number =>
|
|
97
|
+
new Set(values.map((value: string): string => TextNormalizer.foldCaseAndDiacritics(value))).size;
|
|
98
|
+
expect(distinct(['\u0e01\u0e34', '\u0e01\u0e35', '\u0e01\u0e38'])).to.equal(3);
|
|
99
|
+
expect(distinct(['\u0915\u093f', '\u0915\u0941', '\u0915\u0947'])).to.equal(3);
|
|
100
|
+
expect(distinct(['\u304b', '\u304c', '\u3071', '\u3070'])).to.equal(4);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('leaves Chinese untouched and unifies the compatibility ideographs', (): void => {
|
|
104
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\u4e2d\u6587')).to.equal('\u4e2d\u6587');
|
|
105
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\uf900')).to.equal('\u8c48');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('normalizeInvisibleCharacters', () => {
|
|
110
|
+
it('returns an empty string unchanged', (): void => {
|
|
111
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('')).to.equal('');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('drops a soft hyphen', (): void => {
|
|
115
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('Do\u00adnau')).to.equal('Donau');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('drops the zero width characters', (): void => {
|
|
119
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('a\u200bb\u200cc\u200dd\u2060e\ufeff')).to.equal('abcde');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('turns a no-break space into a plain space', (): void => {
|
|
123
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('Cr\u00e8me\u00a0br\u00fbl\u00e9e')).to.equal('Cr\u00e8me br\u00fbl\u00e9e');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('turns the other space variants into a plain space', (): void => {
|
|
127
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('a\u2009b\u202fc\u3000d')).to.equal('a b c d');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('leaves an ordinary label untouched', (): void => {
|
|
131
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('Cremeschnitte')).to.equal('Cremeschnitte');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('leaves casing and diacritics to the other function', (): void => {
|
|
135
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('Cr\u00e8me Br\u00fbl\u00e9e')).to.equal('Cr\u00e8me Br\u00fbl\u00e9e');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* Module: @typo3/core/utility/text-normalizer
|
|
16
|
+
*
|
|
17
|
+
* Intl.Collator is deliberately not used: it compares whole strings rather than
|
|
18
|
+
* searching inside one, its result depends on the locale it is given, and at
|
|
19
|
+
* sensitivity "base" it merges the Japanese for "school" and for "cuckoo".
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
class TextNormalizer {
|
|
24
|
+
// The marks a script writes optionally, one constant per script so that each
|
|
25
|
+
// range can be read and checked against Unicode on its own. The marks Thai,
|
|
26
|
+
// Devanagari, Khmer, Lao and Japanese write obligatorily are deliberately
|
|
27
|
+
// absent: there a mark separates one word from another rather than accenting
|
|
28
|
+
// it, so removing it would fold the Japanese for "school" and for "cuckoo"
|
|
29
|
+
// into one value and leave a Thai filter narrowing nothing.
|
|
30
|
+
|
|
31
|
+
/** Combining diacritical marks, written by Latin, Greek and Cyrillic. */
|
|
32
|
+
private static readonly latinGreekCyrillicMarks = /[\u0300-\u036f]/g;
|
|
33
|
+
|
|
34
|
+
/** Hebrew points and cantillation. The gaps are punctuation, not marks. */
|
|
35
|
+
private static readonly hebrewPoints = /[\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7]/g;
|
|
36
|
+
|
|
37
|
+
/** Arabic harakat, hamza and madda, plus the superscript alef. */
|
|
38
|
+
private static readonly arabicHarakat = /[\u064b-\u065f\u0670]/g;
|
|
39
|
+
|
|
40
|
+
// Latin letters Unicode does not decompose, so stripping marks never reaches
|
|
41
|
+
// them. Without these an alphabet folds inconsistently: Danish "å" becomes "a"
|
|
42
|
+
// because it decomposes, while "æ" and "ø" would stay as they are.
|
|
43
|
+
//
|
|
44
|
+
// Only letters whose ASCII spelling is conventional rather than a matter of
|
|
45
|
+
// language are in here. The transcriptions that are a matter of language --
|
|
46
|
+
// German "ü" to "ue" rather than to "u" -- are not, because the decomposition
|
|
47
|
+
// already settles those.
|
|
48
|
+
|
|
49
|
+
/** Their conventional ASCII spelling, applied after the marks are stripped. */
|
|
50
|
+
private static readonly latinExpansions = new Map([
|
|
51
|
+
['ß', 'ss'], ['æ', 'ae'], ['œ', 'oe'], ['þ', 'th'],
|
|
52
|
+
['ø', 'o'], ['đ', 'd'], ['ð', 'd'], ['ł', 'l'],
|
|
53
|
+
['ħ', 'h'], ['ı', 'i'], ['ĸ', 'k'], ['ŋ', 'n'],
|
|
54
|
+
['ŧ', 't'],
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** Built from the map above so the letters are listed in one place only. */
|
|
58
|
+
private static readonly latinExpansionPattern =
|
|
59
|
+
new RegExp('[' + [...TextNormalizer.latinExpansions.keys()].join('') + ']', 'g');
|
|
60
|
+
|
|
61
|
+
/** Characters that take no width, so a reader cannot tell they are there. */
|
|
62
|
+
private static readonly zeroWidthCharacters = /[\u00ad\u200b-\u200d\u2060\ufeff]/g;
|
|
63
|
+
|
|
64
|
+
/** The space separators, all compared as a plain U+0020. */
|
|
65
|
+
private static readonly spaceVariants = /[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/g;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Lowercase, strip the optional marks and spell out the Latin letters that do
|
|
69
|
+
* not decompose, so that a filter matches "Crème" when "creme" is typed,
|
|
70
|
+
* "Straße" when "strasse" is typed, and an Arabic label carrying its harakat
|
|
71
|
+
* when it is typed without them.
|
|
72
|
+
*
|
|
73
|
+
* toLocaleLowerCase() is avoided: it follows the browser UI locale, where "I"
|
|
74
|
+
* folds to a dotless "ı".
|
|
75
|
+
*/
|
|
76
|
+
public static foldCaseAndDiacritics(value: string): string {
|
|
77
|
+
return value
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.normalize('NFD')
|
|
80
|
+
.replace(TextNormalizer.latinGreekCyrillicMarks, '')
|
|
81
|
+
.replace(TextNormalizer.hebrewPoints, '')
|
|
82
|
+
.replace(TextNormalizer.arabicHarakat, '')
|
|
83
|
+
.replace(TextNormalizer.latinExpansionPattern, (letter: string): string => TextNormalizer.latinExpansions.get(letter));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drop the characters a reader cannot see and unify the space variants, so
|
|
88
|
+
* that a label carrying a soft hyphen or a no-break space still matches what
|
|
89
|
+
* is typed. Both arrive in labels by pasting.
|
|
90
|
+
*/
|
|
91
|
+
public static normalizeInvisibleCharacters(value: string): string {
|
|
92
|
+
return value
|
|
93
|
+
.replace(TextNormalizer.zeroWidthCharacters, '')
|
|
94
|
+
.replace(TextNormalizer.spaceVariants, ' ');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default TextNormalizer;
|
package/dashboard/dashboard.ts
CHANGED
|
@@ -565,10 +565,10 @@ export class Dashboard extends LitElement {
|
|
|
565
565
|
|
|
566
566
|
private editDashboard(dashboard: DashboardInterface): void {
|
|
567
567
|
const content = html`
|
|
568
|
-
<form>
|
|
568
|
+
<form id="dashboard-edit-form">
|
|
569
569
|
<div class="form-group">
|
|
570
570
|
<label class="form-label" for="dashboard-form-edit-title">${labels.get('dashboard.title')}</label>
|
|
571
|
-
<input class="form-control" id="dashboard-form-edit-title" type="text" name="title" value=${dashboard.title || ''} required="required">
|
|
571
|
+
<input class="form-control" id="dashboard-form-edit-title" type="text" name="title" value=${dashboard.title || ''} required="required" autofocus>
|
|
572
572
|
</div>
|
|
573
573
|
</form>
|
|
574
574
|
`;
|
|
@@ -580,11 +580,6 @@ export class Dashboard extends LitElement {
|
|
|
580
580
|
severity: SeverityEnum.notice,
|
|
581
581
|
content,
|
|
582
582
|
callback: (currentModal: ModalElement): void => {
|
|
583
|
-
|
|
584
|
-
currentModal.addEventListener('typo3-modal-shown', (): void => {
|
|
585
|
-
(currentModal.querySelector('#dashboard-form-edit-title') as HTMLInputElement)?.focus();
|
|
586
|
-
});
|
|
587
|
-
|
|
588
583
|
currentModal.querySelector('form').addEventListener('submit', (e: Event): void => {
|
|
589
584
|
e.preventDefault();
|
|
590
585
|
const form = e.target as HTMLFormElement;
|
|
@@ -608,7 +603,7 @@ export class Dashboard extends LitElement {
|
|
|
608
603
|
text: labels.get('dashboard.configure.button.ok'),
|
|
609
604
|
btnClass: 'btn-primary',
|
|
610
605
|
name: 'save',
|
|
611
|
-
|
|
606
|
+
form: 'dashboard-edit-form',
|
|
612
607
|
},
|
|
613
608
|
]
|
|
614
609
|
});
|
|
@@ -51,10 +51,7 @@ class FileListRenameHandler {
|
|
|
51
51
|
text: labels.get('file_rename.button.rename'),
|
|
52
52
|
btnClass: 'btn-primary',
|
|
53
53
|
name: 'rename',
|
|
54
|
-
|
|
55
|
-
const form: HTMLFormElement = modal.querySelector('form');
|
|
56
|
-
form?.requestSubmit();
|
|
57
|
-
},
|
|
54
|
+
form: 'file-list-rename-form',
|
|
58
55
|
},
|
|
59
56
|
],
|
|
60
57
|
callback: function (modal: ModalElement) {
|
|
@@ -119,11 +116,11 @@ class FileListRenameHandler {
|
|
|
119
116
|
labels.get('folder_rename.label') :
|
|
120
117
|
labels.get('file_rename.label');
|
|
121
118
|
return html`
|
|
122
|
-
<form>
|
|
119
|
+
<form id="file-list-rename-form">
|
|
123
120
|
<label class="form-label" for="rename_target">
|
|
124
121
|
${label}
|
|
125
122
|
</label>
|
|
126
|
-
<input id="rename_target" name="name" class="form-control" value="${resource.name}" required>
|
|
123
|
+
<input id="rename_target" name="name" class="form-control" value="${resource.name}" required autofocus>
|
|
127
124
|
</form>
|
|
128
125
|
`;
|
|
129
126
|
}
|
|
@@ -57,10 +57,7 @@ class FileListReplaceHandler {
|
|
|
57
57
|
text: labels.get('file_replace.button.replace'),
|
|
58
58
|
btnClass: 'btn-primary',
|
|
59
59
|
name: 'rename',
|
|
60
|
-
|
|
61
|
-
const form: HTMLFormElement = modal.querySelector('form');
|
|
62
|
-
form?.requestSubmit();
|
|
63
|
-
},
|
|
60
|
+
form: 'file-list-replace-form',
|
|
64
61
|
},
|
|
65
62
|
],
|
|
66
63
|
callback: function (modal: ModalElement) {
|
|
@@ -126,10 +123,10 @@ class FileListReplaceHandler {
|
|
|
126
123
|
</dl>
|
|
127
124
|
</div>
|
|
128
125
|
</div>
|
|
129
|
-
<form>
|
|
126
|
+
<form id="file-list-replace-form">
|
|
130
127
|
<div class="form-group">
|
|
131
128
|
<label class="form-label" for="file_replace">${labels.get('file_replace.new_file.label')}</label>
|
|
132
|
-
<input id="file_replace" type="file" class="form-control" name="replace_1">
|
|
129
|
+
<input id="file_replace" type="file" class="form-control" name="replace_1" autofocus>
|
|
133
130
|
</div>
|
|
134
131
|
<div class="form-check">
|
|
135
132
|
<input type="checkbox" value="1" id="keepFilename" name="keepFilename" class="form-check-input" checked>
|
package/filelist/file-list.ts
CHANGED
|
@@ -422,10 +422,9 @@ export default class Filelist {
|
|
|
422
422
|
}
|
|
423
423
|
trigger.value = value.toString(10);
|
|
424
424
|
if (e.key === 'Enter' && value !== Number(trigger.dataset.currentpage)) {
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
window.location.href = submitUrl.toString();
|
|
425
|
+
const pagingUrl = new URL(trigger.dataset.pagingUrl, window.origin);
|
|
426
|
+
pagingUrl.searchParams.set('currentPage', value.toString());
|
|
427
|
+
window.location.href = pagingUrl.toString();
|
|
429
428
|
}
|
|
430
429
|
});
|
|
431
430
|
});
|