typo3-typescript-sources 14.3.5 → 14.3.7

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 (46) 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/element/contextual-record-edit-trigger.ts +9 -1
  5. package/backend/form-engine/element/extra/select-box-filter.ts +8 -5
  6. package/backend/form-engine/element/select-tree.ts +16 -2
  7. package/backend/form-engine/element/slug-element.ts +13 -5
  8. package/backend/form-engine/field-control/add-record.ts +27 -1
  9. package/backend/form-engine.ts +80 -1
  10. package/backend/layout-module/sticky-language-header.ts +186 -0
  11. package/backend/live-search/element/result/item/item.ts +2 -0
  12. package/backend/live-search/element/result/result-detail-container.ts +14 -1
  13. package/backend/localization/localization-wizard.ts +3 -0
  14. package/backend/localization/steps/handler-selection-step.ts +12 -17
  15. package/backend/localization/steps/mode-step.ts +9 -14
  16. package/backend/localization/steps/source-language-step.ts +8 -13
  17. package/backend/localization/steps/target-language-step.ts +12 -17
  18. package/backend/modal.ts +124 -1
  19. package/backend/module-docheader.ts +58 -0
  20. package/backend/page-wizard/steps/position-step.ts +5 -0
  21. package/backend/tests/form-engine/element/extra/select-box-filter-test.ts +215 -0
  22. package/backend/tests/form-engine/element/select-tree-test.ts +121 -0
  23. package/backend/tree/page-position-select.ts +25 -0
  24. package/backend/wizard/finisher/event-finisher.ts +62 -0
  25. package/backend/wizard/steps/finisher-step.ts +2 -2
  26. package/backend/wizard/wizard.ts +110 -7
  27. package/core/ajax/ajax-request.ts +18 -0
  28. package/core/tests/ajax/ajax-request-test.ts +1 -1
  29. package/core/tests/utility/text-normalizer-test.ts +138 -0
  30. package/core/utility/text-normalizer.ts +98 -0
  31. package/dashboard/dashboard-wizard.ts +86 -0
  32. package/dashboard/dashboard.ts +28 -76
  33. package/dashboard/wizard/finisher/dashboard-wizard-submission-service.ts +43 -0
  34. package/dashboard/wizard/steps/preset-step.ts +164 -0
  35. package/dashboard/wizard/steps/title-step.ts +90 -0
  36. package/form/backend/form-editor/core.ts +22 -9
  37. package/form/backend/form-editor/inspector-component.ts +61 -38
  38. package/form/backend/form-editor/stage-component.ts +2 -2
  39. package/form/backend/form-editor/view-model.ts +1 -1
  40. package/form/backend/form-wizard/form-wizard.ts +1 -0
  41. package/form/backend/form-wizard/steps/settings-step.ts +6 -13
  42. package/package.json +2 -2
  43. package/workspaces/backend.ts +7 -2
  44. package/workspaces/renderable/comment-view.ts +4 -2
  45. package/workspaces/renderable/record-table.ts +41 -4
  46. package/workspaces/workspaces.ts +65 -12
@@ -37,24 +37,19 @@ export class TargetLanguageStep implements WizardStepInterface, WizardStepValueI
37
37
  constructor(private readonly context: LocalizationContext) {
38
38
  this.task = new Task(this.context.wizard, {
39
39
  task: async ([recordType, recordUid]: [string, number]): Promise<LocalizationLanguageRecord[]> => {
40
- try {
41
- const response = await new AjaxRequest(TYPO3.settings.ajaxUrls.wizard_localization_get_targets).withQueryArguments({
42
- recordType: recordType,
43
- recordUid: recordUid,
44
- }).get();
45
- let targetLanguages: LocalizationLanguageRecord[] = await response.resolve();
46
-
47
- // Filter to only the predefined target language if one exists vsia property
48
- const predefinedTargetLanguage = this.context.targetLanguage;
49
- if (predefinedTargetLanguage != null) {
50
- targetLanguages = targetLanguages.filter(lang => lang.uid === predefinedTargetLanguage);
51
- }
52
-
53
- return targetLanguages;
54
- } catch (error) {
55
- console.warn('Failed to fetch target languages:', error);
56
- return [];
40
+ const response = await new AjaxRequest(TYPO3.settings.ajaxUrls.wizard_localization_get_targets).withQueryArguments({
41
+ recordType: recordType,
42
+ recordUid: recordUid,
43
+ }).get();
44
+ let targetLanguages: LocalizationLanguageRecord[] = await response.resolve();
45
+
46
+ // Filter to only the predefined target language if one exists vsia property
47
+ const predefinedTargetLanguage = this.context.targetLanguage;
48
+ if (predefinedTargetLanguage != null) {
49
+ targetLanguages = targetLanguages.filter(lang => lang.uid === predefinedTargetLanguage);
57
50
  }
51
+
52
+ return targetLanguages;
58
53
  },
59
54
  args: () => [this.context.recordType, this.context.recordUid],
60
55
  autoRun: false
package/backend/modal.ts CHANGED
@@ -16,6 +16,7 @@ import { customElement, property, state, query } from 'lit/decorators.js';
16
16
  import { unsafeHTML } from 'lit/directives/unsafe-html.js';
17
17
  import { classMap, type ClassInfo } from 'lit/directives/class-map.js';
18
18
  import { ifDefined } from 'lit/directives/if-defined.js';
19
+ import { styleMap, type StyleInfo } from 'lit/directives/style-map.js';
19
20
  import { classesArrayToClassInfo } from '@typo3/core/lit-helper';
20
21
  import RegularEvent from '@typo3/core/event/regular-event';
21
22
  import type { AjaxResponse } from '@typo3/core/ajax/ajax-response';
@@ -23,6 +24,7 @@ import type { AbstractAction } from './action-button/abstract-action';
23
24
  import type { ModalResponseEvent } from '@typo3/backend/modal-interface';
24
25
  import { SeverityEnum } from './enum/severity';
25
26
  import AjaxRequest from '@typo3/core/ajax/ajax-request';
27
+ import Persistent from '@typo3/backend/storage/persistent';
26
28
  import Severity from './severity';
27
29
  import '@typo3/backend/element/icon-element';
28
30
  import '@typo3/backend/element/spinner-element';
@@ -121,6 +123,7 @@ export interface Configuration {
121
123
  staticBackdrop: boolean;
122
124
  hideCloseButton: boolean;
123
125
  hideHeader: boolean;
126
+ resizeIdentifier: string;
124
127
  }
125
128
 
126
129
  type PartialConfiguration = Partial<Omit<Configuration, 'buttons'> & { buttons: Array<Partial<Button>> }>;
@@ -129,6 +132,8 @@ let uniqueIdCounter = 0;
129
132
 
130
133
  @customElement('typo3-backend-modal')
131
134
  export class ModalElement extends LitElement {
135
+ private static readonly RESIZE_MIN_WIDTH = 400;
136
+
132
137
  @property({ type: String, reflect: true }) modalTitle: string = '';
133
138
  @property({ type: String, reflect: true }) content: string = '';
134
139
  @property({ type: String, reflect: true }) type: Types = Types.default;
@@ -140,10 +145,13 @@ export class ModalElement extends LitElement {
140
145
  @property({ type: Boolean }) hideHeader: boolean = false;
141
146
  @property({ type: Array }) additionalCssClasses: Array<string> = [];
142
147
  @property({ type: Array, attribute: false }) buttons: Array<Button> = [];
148
+ @property({ type: String, attribute: 'resize-identifier' }) resizeIdentifier: string = '';
143
149
 
144
150
  @state() templateResultContent: TemplateResult | JQuery | Element | DocumentFragment = null;
145
151
  @state() activeButton: Button = null;
146
152
  @query('dialog', true) dialog: HTMLDialogElement;
153
+ @state() private modalWidth?: number;
154
+ @state() private resizing: boolean = false;
147
155
 
148
156
  public callback: ModalCallbackFunction = null;
149
157
  public ajaxCallback: ModalCallbackFunction = null;
@@ -151,6 +159,7 @@ export class ModalElement extends LitElement {
151
159
  public userData: { [key: string]: any } = {};
152
160
 
153
161
  private readonly uniqueId: number;
162
+ private resizeReferencePosition: number = 0;
154
163
  #size: Sizes | SizeConfig = Sizes.default;
155
164
 
156
165
  constructor() {
@@ -196,6 +205,13 @@ export class ModalElement extends LitElement {
196
205
  }
197
206
  }
198
207
 
208
+ public override connectedCallback(): void {
209
+ super.connectedCallback();
210
+ if (this.isResizable()) {
211
+ this.loadPersistedWidth();
212
+ }
213
+ }
214
+
199
215
  public setContent(content: TemplateResult | JQuery | Element | DocumentFragment): void {
200
216
  this.templateResultContent = content;
201
217
  }
@@ -256,6 +272,7 @@ export class ModalElement extends LitElement {
256
272
  }
257
273
 
258
274
  protected override render(): TemplateResult {
275
+ const resizable = this.isResizable();
259
276
  const classes: ClassInfo = classesArrayToClassInfo([
260
277
  'modal',
261
278
  't3js-modal',
@@ -264,11 +281,18 @@ export class ModalElement extends LitElement {
264
281
  `modal-severity-${Severity.getCssClass(this.severity)}`,
265
282
  ...this.getSizeClasses(),
266
283
  `modal-position-${this.position}`,
284
+ ...(resizable ? ['modal-resizable'] : []),
285
+ ...(this.resizing ? ['modal-resizing'] : []),
267
286
  ...this.additionalCssClasses,
268
287
  ]);
288
+ const dialogStyles: StyleInfo = {};
289
+ if (resizable && this.modalWidth) {
290
+ dialogStyles['--typo3-modal-width'] = `${this.modalWidth}px`;
291
+ }
269
292
  return html`
270
293
  <dialog
271
294
  class=${classMap(classes)}
295
+ style=${styleMap(dialogStyles)}
272
296
  aria-labelledby=${ifDefined(this.hideHeader ? undefined : `t3-modal-header-${this.uniqueId}`)}
273
297
  aria-label=${ifDefined(this.hideHeader ? this.modalTitle : undefined)}
274
298
  closedby=${this.hideCloseButton ? 'none' : 'closerequest'}
@@ -276,6 +300,7 @@ export class ModalElement extends LitElement {
276
300
  @cancel=${this.handleDialogCancel}
277
301
  @click=${this.handleDialogClick}
278
302
  >
303
+ ${resizable ? html`<div class="modal-resize-handle" @pointerdown=${this.startResize}></div>` : nothing}
279
304
  ${this.hideHeader ? nothing : html`
280
305
  <div class="modal-header t3js-modal-header">
281
306
  <div class="modal-header-title t3js-modal-title" id="t3-modal-header-${this.uniqueId}">${this.modalTitle}</div>
@@ -480,6 +505,99 @@ export class ModalElement extends LitElement {
480
505
  { transform: 'translateX(0px)' },
481
506
  ], 150);
482
507
  }
508
+
509
+ /**
510
+ * Currently only "sheet" positioned modals (drawers docked to the viewport edge)
511
+ * support resizing, as it is the only position with a single free-floating edge.
512
+ */
513
+ private isResizable(): boolean {
514
+ return this.position === Positions.sheet;
515
+ }
516
+
517
+ private getResizePersistenceKey(): string | null {
518
+ if (!this.resizeIdentifier) {
519
+ return null;
520
+ }
521
+ return `resize.${this.resizeIdentifier}.modal`;
522
+ }
523
+
524
+ private loadPersistedWidth(): void {
525
+ const key = this.getResizePersistenceKey();
526
+ if (!key) {
527
+ return;
528
+ }
529
+ const stored = Persistent.get(key);
530
+ if (stored) {
531
+ const width = parseInt(stored, 10);
532
+ if (!isNaN(width) && width > 0) {
533
+ this.modalWidth = width;
534
+ }
535
+ }
536
+ }
537
+
538
+ private persistWidth(): void {
539
+ const key = this.getResizePersistenceKey();
540
+ if (key && this.modalWidth) {
541
+ Persistent.set(key, String(this.modalWidth));
542
+ }
543
+ }
544
+
545
+ private getMaxWidth(): number {
546
+ const computedMaxWidth = parseFloat(getComputedStyle(this.dialog).maxWidth);
547
+ return Number.isNaN(computedMaxWidth) ? window.innerWidth : computedMaxWidth;
548
+ }
549
+
550
+ private isRtl(): boolean {
551
+ return getComputedStyle(this).direction === 'rtl';
552
+ }
553
+
554
+ /**
555
+ * A "sheet" is docked to the inline-end edge (via margin-inline-end: 0), so its
556
+ * free edge to grab and drag is always the inline-start edge, in both directions.
557
+ */
558
+ private readonly startResize = (event: PointerEvent) => {
559
+ if (event.button !== 0) {
560
+ return;
561
+ }
562
+ event.stopPropagation();
563
+ event.preventDefault();
564
+
565
+ const rect = this.dialog.getBoundingClientRect();
566
+ this.resizeReferencePosition = this.isRtl() ? rect.left : rect.right;
567
+
568
+ this.resizing = true;
569
+ const target = event.target as HTMLElement;
570
+ target.setPointerCapture(event.pointerId);
571
+ target.addEventListener('pointermove', this.handlePointerMove);
572
+ target.addEventListener('pointerup', this.handlePointerUp);
573
+ target.addEventListener('pointercancel', this.handlePointerUp);
574
+ target.addEventListener('lostpointercapture', this.handlePointerUp);
575
+ };
576
+
577
+ private readonly handlePointerMove = (event: PointerEvent) => {
578
+ if (!this.resizing) {
579
+ return;
580
+ }
581
+
582
+ const maxWidth = this.getMaxWidth();
583
+ let width = this.isRtl()
584
+ ? Math.round(event.clientX - this.resizeReferencePosition)
585
+ : Math.round(this.resizeReferencePosition - event.clientX);
586
+
587
+ width = Math.max(ModalElement.RESIZE_MIN_WIDTH, Math.min(width, maxWidth));
588
+ this.modalWidth = width;
589
+ this.dialog.style.setProperty('--typo3-modal-width', `${width}px`);
590
+ };
591
+
592
+ private readonly handlePointerUp = (event: PointerEvent) => {
593
+ const target = event.currentTarget as HTMLElement;
594
+ target.removeEventListener('pointermove', this.handlePointerMove);
595
+ target.removeEventListener('pointerup', this.handlePointerUp);
596
+ target.removeEventListener('pointercancel', this.handlePointerUp);
597
+ target.removeEventListener('lostpointercapture', this.handlePointerUp);
598
+ this.resizing = false;
599
+ this.persistWidth();
600
+ };
483
601
  }
484
602
 
485
603
  declare global {
@@ -517,7 +635,8 @@ class Modal {
517
635
  ajaxCallback: null,
518
636
  staticBackdrop: false,
519
637
  hideCloseButton: false,
520
- hideHeader: false
638
+ hideHeader: false,
639
+ resizeIdentifier: '',
521
640
  };
522
641
 
523
642
  constructor() {
@@ -691,6 +810,9 @@ class Modal {
691
810
  configuration.hideCloseButton = configuration.hideCloseButton || this.defaultConfiguration.hideCloseButton;
692
811
  configuration.staticBackdrop = configuration.staticBackdrop || this.defaultConfiguration.staticBackdrop;
693
812
  configuration.hideHeader = configuration.hideHeader || this.defaultConfiguration.hideHeader;
813
+ configuration.resizeIdentifier = typeof configuration.resizeIdentifier === 'string'
814
+ ? configuration.resizeIdentifier
815
+ : this.defaultConfiguration.resizeIdentifier;
694
816
 
695
817
  return this.generate(configuration);
696
818
  }
@@ -813,6 +935,7 @@ class Modal {
813
935
  currentModal.hideCloseButton = configuration.hideCloseButton;
814
936
  currentModal.staticBackdrop = configuration.staticBackdrop;
815
937
  currentModal.hideHeader = configuration.hideHeader;
938
+ currentModal.resizeIdentifier = configuration.resizeIdentifier;
816
939
  if (configuration.callback) {
817
940
  currentModal.callback = configuration.callback;
818
941
  }
@@ -0,0 +1,58 @@
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 DocumentService from '@typo3/core/document-service';
15
+
16
+ enum Selectors {
17
+ wrapper = '.module-docheader-wrapper',
18
+ navigation = '.t3js-module-docheader-navigation',
19
+ }
20
+
21
+ /**
22
+ * Module: @typo3/backend/module-docheader
23
+ *
24
+ * Publishes the measured docheader heights as CSS custom properties, because
25
+ * both bars wrap and the stylesheet cannot know how much of it stays pinned.
26
+ */
27
+ class ModuleDocHeader {
28
+ private readonly resizeObserver: ResizeObserver;
29
+ private wrapper: HTMLElement | null = null;
30
+ private navigation: HTMLElement | null = null;
31
+
32
+ constructor() {
33
+ this.resizeObserver = new ResizeObserver((): void => this.publish());
34
+ DocumentService.ready().then((): void => this.observe());
35
+ }
36
+
37
+ private observe(): void {
38
+ this.wrapper = document.querySelector(Selectors.wrapper);
39
+ if (this.wrapper === null) {
40
+ return;
41
+ }
42
+
43
+ this.navigation = this.wrapper.querySelector(Selectors.navigation);
44
+ // observing delivers an initial record, which is what publishes the first values
45
+ this.resizeObserver.observe(this.wrapper);
46
+ if (this.navigation !== null) {
47
+ this.resizeObserver.observe(this.navigation);
48
+ }
49
+ }
50
+
51
+ private publish(): void {
52
+ const style = document.documentElement.style;
53
+ style.setProperty('--module-docheader-height', `${this.wrapper.getBoundingClientRect().height}px`);
54
+ style.setProperty('--module-docheader-navigation-height', `${this.navigation?.getBoundingClientRect().height ?? 0}px`);
55
+ }
56
+ }
57
+
58
+ export default new ModuleDocHeader();
@@ -80,6 +80,7 @@ export class PositionStep implements WizardStepInterface, WizardStepValueInterfa
80
80
  .activePageId="${currentValue?.pageUid}"
81
81
  .insertPosition="${currentValue?.insertPosition}"
82
82
  @typo3:page-position-select-tree:insert-position-change=${(event: CustomEvent) => this.handleInsertPositionChange(event)}
83
+ @typo3:page-position-select-tree:insert-position-confirm=${() => this.handleInsertPositionConfirm()}
83
84
  >
84
85
  </typo3-backend-component-page-position-select>
85
86
  `;
@@ -141,6 +142,10 @@ export class PositionStep implements WizardStepInterface, WizardStepValueInterfa
141
142
  });
142
143
  }
143
144
 
145
+ private handleInsertPositionConfirm(): void {
146
+ this.context.dispatchAutoAdvance();
147
+ }
148
+
144
149
  private initSummaryTask(): void {
145
150
  this.summaryTask = new Task(this.context.wizard, {
146
151
  task: async ([pageUid]: [number]): Promise<PageDetail> => {
@@ -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
+ });