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.
- package/.last-synced-tag +1 -1
- package/README-TYPO3.md +1 -1
- package/README.md +1 -1
- 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/select-tree.ts +16 -2
- package/backend/form-engine/element/slug-element.ts +13 -5
- package/backend/form-engine/field-control/add-record.ts +27 -1
- package/backend/form-engine.ts +80 -1
- package/backend/layout-module/sticky-language-header.ts +186 -0
- package/backend/live-search/element/result/item/item.ts +2 -0
- package/backend/live-search/element/result/result-detail-container.ts +14 -1
- 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/modal.ts +124 -1
- package/backend/module-docheader.ts +58 -0
- package/backend/page-wizard/steps/position-step.ts +5 -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/tree/page-position-select.ts +25 -0
- package/backend/wizard/finisher/event-finisher.ts +62 -0
- package/backend/wizard/steps/finisher-step.ts +2 -2
- package/backend/wizard/wizard.ts +110 -7
- 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-wizard.ts +86 -0
- package/dashboard/dashboard.ts +28 -76
- package/dashboard/wizard/finisher/dashboard-wizard-submission-service.ts +43 -0
- package/dashboard/wizard/steps/preset-step.ts +164 -0
- package/dashboard/wizard/steps/title-step.ts +90 -0
- package/form/backend/form-editor/core.ts +22 -9
- package/form/backend/form-editor/inspector-component.ts +61 -38
- package/form/backend/form-editor/stage-component.ts +2 -2
- package/form/backend/form-editor/view-model.ts +1 -1
- package/form/backend/form-wizard/form-wizard.ts +1 -0
- package/form/backend/form-wizard/steps/settings-step.ts +6 -13
- package/package.json +2 -2
- package/workspaces/backend.ts +7 -2
- package/workspaces/renderable/comment-view.ts +4 -2
- package/workspaces/renderable/record-table.ts +41 -4
- 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
});
|