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
@@ -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
- params = '&data[pages][' + data.node.identifier + '][title]=' + encodeURIComponent(data.title);
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> {
@@ -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?.resetButtonTitle) {
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="actions-arrow-end-alt" bidi size="small"></typo3-backend-icon>
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;
@@ -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
- trigger: (e, modal) => modal.querySelector('form').requestSubmit(),
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
- trigger: (): void => {
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
- trigger: (): void => {
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>
@@ -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 form = trigger.closest('form[name="fileListForm"]') as HTMLFormElement;
426
- const submitUrl = new URL(form.action, window.origin);
427
- submitUrl.searchParams.set('currentPage', value.toString());
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
  });