typo3-typescript-sources 14.3.6 → 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/field-control/add-record.ts +27 -1
- package/backend/form-engine.ts +80 -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/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/wizard/steps/finisher-step.ts +2 -2
- package/backend/wizard/wizard.ts +11 -4
- package/core/ajax/ajax-request.ts +18 -0
- package/core/tests/ajax/ajax-request-test.ts +1 -1
- package/core/tests/utility/text-normalizer-test.ts +138 -0
- package/core/utility/text-normalizer.ts +98 -0
- package/form/backend/form-editor/view-model.ts +1 -1
- package/form/backend/form-wizard/steps/settings-step.ts +6 -3
- package/package.json +2 -2
- package/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
package/.last-synced-tag
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
v14.3.
|
|
1
|
+
v14.3.7
|
package/README-TYPO3.md
CHANGED
|
@@ -75,7 +75,7 @@ with each other and with the public. Several TYPO3 teams use Slack as a
|
|
|
75
75
|
way to communicate internally and most channels are a welcome place for
|
|
76
76
|
you to join and get yourself involved.
|
|
77
77
|
|
|
78
|
-
* Register: https://
|
|
78
|
+
* Register: https://typo3.community/meet/slack/
|
|
79
79
|
* Slack: https://typo3.slack.com/
|
|
80
80
|
|
|
81
81
|
### Exchange information, ask questions, get help
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ npm install typo3-typescript-sources
|
|
|
16
16
|
|
|
17
17
|
- **Original Repository:** [TYPO3/typo3](https://github.com/TYPO3/typo3)
|
|
18
18
|
- **Source Path:** `Build/Sources/TypeScript/`
|
|
19
|
-
- **Current TYPO3 Version:** `v14.3.
|
|
19
|
+
- **Current TYPO3 Version:** `v14.3.7`
|
|
20
20
|
|
|
21
21
|
## 🌿 Branches
|
|
22
22
|
|
|
@@ -27,11 +27,17 @@ import labels from '~labels/backend.alt_doc';
|
|
|
27
27
|
*
|
|
28
28
|
* `url` attribute points to the contextual edit route
|
|
29
29
|
* `edit-url` points to the full FormEngine route.
|
|
30
|
+
* `context` identifies what's being edited, used to persist the sheet's
|
|
31
|
+
* width independently per context. This might be just the database table
|
|
32
|
+
* (e.g. "pages" and "sys_category" are resized independently), but callers
|
|
33
|
+
* may pass a more specific value later, e.g. "tt_content.textmedia" vs.
|
|
34
|
+
* "tt_content.header", once per-CType widths are needed.
|
|
30
35
|
*
|
|
31
36
|
* @example
|
|
32
37
|
* <typo3-backend-contextual-record-edit-trigger
|
|
33
38
|
* url="/typo3/record/edit/contextual?..."
|
|
34
|
-
* edit-url="/typo3/record/edit?..."
|
|
39
|
+
* edit-url="/typo3/record/edit?..."
|
|
40
|
+
* context="pages">
|
|
35
41
|
* Edit record
|
|
36
42
|
* </typo3-backend-contextual-record-edit-trigger>
|
|
37
43
|
*/
|
|
@@ -39,6 +45,7 @@ import labels from '~labels/backend.alt_doc';
|
|
|
39
45
|
export class ContextualRecordEditTriggerElement extends PseudoButtonLitElement {
|
|
40
46
|
@property({ type: String }) url: string;
|
|
41
47
|
@property({ type: String, attribute: 'edit-url' }) editUrl: string;
|
|
48
|
+
@property({ type: String }) context: string = '';
|
|
42
49
|
|
|
43
50
|
protected override async buttonActivated(): Promise<void> {
|
|
44
51
|
if (Persistent.isset('contextualRecordEdit') && Persistent.get('contextualRecordEdit') == 0) {
|
|
@@ -54,6 +61,7 @@ export class ContextualRecordEditTriggerElement extends PseudoButtonLitElement {
|
|
|
54
61
|
size: Sizes.expand,
|
|
55
62
|
position: Positions.sheet,
|
|
56
63
|
hideHeader: true,
|
|
64
|
+
resizeIdentifier: `contextualEdit${this.context ? `.${this.context}` : ''}`,
|
|
57
65
|
});
|
|
58
66
|
this.setupMessageHandling(modal);
|
|
59
67
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import RegularEvent from '@typo3/core/event/regular-event';
|
|
15
|
+
import TextNormalizer from '@typo3/core/utility/text-normalizer';
|
|
15
16
|
|
|
16
17
|
enum Selectors {
|
|
17
18
|
fieldContainerSelector = '.t3js-formengine-field-group',
|
|
@@ -25,6 +26,7 @@ enum Selectors {
|
|
|
25
26
|
class SelectBoxFilter {
|
|
26
27
|
private readonly selectElement: HTMLSelectElement = null;
|
|
27
28
|
private availableOptions: NodeListOf<HTMLOptionElement> = null;
|
|
29
|
+
private foldedLabels: string[] = null;
|
|
28
30
|
|
|
29
31
|
constructor(selectElement: HTMLSelectElement) {
|
|
30
32
|
this.selectElement = selectElement;
|
|
@@ -73,14 +75,15 @@ class SelectBoxFilter {
|
|
|
73
75
|
private filter(filterText: string): void {
|
|
74
76
|
if (this.availableOptions === null) {
|
|
75
77
|
this.availableOptions = this.selectElement.querySelectorAll('option');
|
|
78
|
+
this.foldedLabels = Array.from(this.availableOptions, (option: HTMLOptionElement): string =>
|
|
79
|
+
TextNormalizer.foldCaseAndDiacritics(TextNormalizer.normalizeInvisibleCharacters(option.textContent)));
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
//
|
|
79
|
-
const
|
|
82
|
+
// A plain substring match, no RegExp involved, therefore nothing to escape either
|
|
83
|
+
const foldedFilterText = TextNormalizer.foldCaseAndDiacritics(TextNormalizer.normalizeInvisibleCharacters(filterText));
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
option.hidden = filterText.length > 0 && option.textContent.match(matchFilter) === null;
|
|
85
|
+
this.availableOptions.forEach((option: HTMLOptionElement, index: number): void => {
|
|
86
|
+
option.hidden = filterText.length > 0 && !this.foldedLabels[index].includes(foldedFilterText);
|
|
84
87
|
SelectBoxFilter.toggleOptGroup(option);
|
|
85
88
|
});
|
|
86
89
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { html, type TemplateResult } from 'lit';
|
|
15
15
|
import { Tree, type TreeSettings } from '@typo3/backend/tree/tree';
|
|
16
16
|
import { customElement, state } from 'lit/decorators.js';
|
|
17
|
+
import TextNormalizer from '@typo3/core/utility/text-normalizer';
|
|
17
18
|
import type { TreeNodeInterface } from '@typo3/backend/tree/tree-node';
|
|
18
19
|
|
|
19
20
|
interface SelectTreeSettings extends TreeSettings {
|
|
@@ -48,6 +49,18 @@ export class SelectTree extends Tree
|
|
|
48
49
|
this.addEventListener('typo3:tree:nodes-prepared', this.prepareLoadedNodes);
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Fold both sides of the comparison the filter performs, so that a node is
|
|
54
|
+
* found whether or not the diacritics of its name are typed.
|
|
55
|
+
*
|
|
56
|
+
* The names are folded on every keystroke rather than kept beside the node,
|
|
57
|
+
* because writing to a node triggers a re-render and the nodes are replaced
|
|
58
|
+
* whenever a part of the tree is loaded.
|
|
59
|
+
*/
|
|
60
|
+
private static fold(value: string): string {
|
|
61
|
+
return TextNormalizer.foldCaseAndDiacritics(TextNormalizer.normalizeInvisibleCharacters(value));
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
/**
|
|
52
65
|
* Expand all nodes and refresh view
|
|
53
66
|
*/
|
|
@@ -89,7 +102,8 @@ export class SelectTree extends Tree
|
|
|
89
102
|
this.nodes[0].__expanded = false;
|
|
90
103
|
}
|
|
91
104
|
const firstNode = this.nodes[0];
|
|
92
|
-
|
|
105
|
+
// A plain substring match, no RegExp involved, therefore nothing to escape either
|
|
106
|
+
const foldedSearchTerm = SelectTree.fold(searchTerm ?? '');
|
|
93
107
|
|
|
94
108
|
this.nodes.forEach((node: any) => {
|
|
95
109
|
// skip the root node in searches
|
|
@@ -100,7 +114,7 @@ export class SelectTree extends Tree
|
|
|
100
114
|
node.__expanded = false;
|
|
101
115
|
node.__hidden = true;
|
|
102
116
|
|
|
103
|
-
if (
|
|
117
|
+
if (SelectTree.fold(node.name).includes(foldedSearchTerm)) {
|
|
104
118
|
results.push(node);
|
|
105
119
|
}
|
|
106
120
|
});
|
|
@@ -33,7 +33,33 @@ class AddRecord {
|
|
|
33
33
|
private readonly registerClickHandler = (e: Event): void => {
|
|
34
34
|
e.preventDefault();
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
const href = this.controlElement.getAttribute('href');
|
|
37
|
+
const url = new URL(href, window.location.origin);
|
|
38
|
+
const ownerUid = url.searchParams.get('P[uid]') || '';
|
|
39
|
+
if (ownerUid.startsWith('NEW')) {
|
|
40
|
+
// The record this field belongs to is itself still unsaved:
|
|
41
|
+
// Wizard/AddController can only link a newly created record back into
|
|
42
|
+
// an already-persisted uid, so a plain "leave without saving?" prompt
|
|
43
|
+
// would both discard this record AND silently fail to link anything.
|
|
44
|
+
// Deliberately not passing along this href's own P[returnUrl] here: for
|
|
45
|
+
// a field belonging to an inline/IRRE child that was itself added via
|
|
46
|
+
// AJAX (record_inline_create), that value is whatever URL rendered the
|
|
47
|
+
// child's markup at ajax-creation time. The ajax endpoint's own URL,
|
|
48
|
+
// not a real, navigable page. EditDocumentController computes the
|
|
49
|
+
// correct return-to-this-document URL itself once the save completes.
|
|
50
|
+
FormEngine.preventFollowAddRecordLinkIfNotSaved({
|
|
51
|
+
originalUid: ownerUid,
|
|
52
|
+
ownerTable: url.searchParams.get('P[table]') || '',
|
|
53
|
+
ownerField: url.searchParams.get('P[field]') || '',
|
|
54
|
+
table: url.searchParams.get('P[params][table]') || '',
|
|
55
|
+
pid: url.searchParams.get('P[params][pid]') || '',
|
|
56
|
+
setValue: url.searchParams.get('P[params][setValue]') || 'append',
|
|
57
|
+
flexFormPath: url.searchParams.get('P[flexFormPath]') || '',
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
FormEngine.preventFollowLinkIfNotSaved(href);
|
|
37
63
|
};
|
|
38
64
|
}
|
|
39
65
|
|
package/backend/form-engine.ts
CHANGED
|
@@ -62,6 +62,7 @@ const enum FormAction {
|
|
|
62
62
|
saveAndView = '_savedokview',
|
|
63
63
|
saveAndNew = '_savedoknew',
|
|
64
64
|
duplicate = '_duplicatedoc',
|
|
65
|
+
saveAndAddRecord = '_savedokaddrecord',
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
type OnChangeFieldHandlerCallback = (data: object, e: Event) => void;
|
|
@@ -134,7 +135,7 @@ export default (function() {
|
|
|
134
135
|
btnClass: 'btn-' + Severity.getCssClass(Severity.warning),
|
|
135
136
|
name: 'ok',
|
|
136
137
|
trigger: () => {
|
|
137
|
-
|
|
138
|
+
modal.hideModal();
|
|
138
139
|
saveDocumentWithoutValidation();
|
|
139
140
|
}
|
|
140
141
|
}
|
|
@@ -900,6 +901,84 @@ export default (function() {
|
|
|
900
901
|
}
|
|
901
902
|
};
|
|
902
903
|
|
|
904
|
+
/**
|
|
905
|
+
* Show modal to confirm following a "+ add related record" (fieldControl.addRecord)
|
|
906
|
+
* link whose OWN record is itself still new/unsaved. Since Wizard/AddController
|
|
907
|
+
* can only link a newly created record back into an already-persisted uid,
|
|
908
|
+
* discarding here means abandoning the whole attempt (closing the document,
|
|
909
|
+
* same as preventExitIfNotSavedCallback) rather than following through to
|
|
910
|
+
* create an orphaned record that could never be linked back anyway. The
|
|
911
|
+
* only way to actually continue to the add-record wizard is "save first".
|
|
912
|
+
*
|
|
913
|
+
* @param {Object.<string, string>} addRecordParams
|
|
914
|
+
* @returns {Boolean}
|
|
915
|
+
*/
|
|
916
|
+
FormEngine.preventFollowAddRecordLinkIfNotSaved = function(addRecordParams: {[key: string]: string}): boolean {
|
|
917
|
+
const title = backendAltDocLabels.get('label.confirm.add_record_unsaved_parent.title');
|
|
918
|
+
const content = backendAltDocLabels.get('label.confirm.add_record_unsaved_parent.content');
|
|
919
|
+
const buttons: Array<{text: string, btnClass: string, name: string, active?: boolean}> = [
|
|
920
|
+
{
|
|
921
|
+
text: backendAltDocLabels.get('buttons.confirm.close_without_save.no'),
|
|
922
|
+
btnClass: 'btn-default',
|
|
923
|
+
name: 'no'
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
text: backendAltDocLabels.get('buttons.confirm.close_without_save.yes'),
|
|
927
|
+
btnClass: 'btn-default',
|
|
928
|
+
name: 'yes'
|
|
929
|
+
}
|
|
930
|
+
];
|
|
931
|
+
if (document.querySelector('.has-error') === null) {
|
|
932
|
+
buttons.push({
|
|
933
|
+
text: backendAltDocLabels.get('buttons.confirm.add_record_unsaved_parent.save'),
|
|
934
|
+
btnClass: 'btn-primary',
|
|
935
|
+
name: 'save',
|
|
936
|
+
active: true
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const modal = Modal.confirm(title, content, Severity.warning, buttons);
|
|
941
|
+
modal.addEventListener('button.clicked', function(e: Event) {
|
|
942
|
+
modal.hideModal();
|
|
943
|
+
const name = (e.target as HTMLButtonElement).name;
|
|
944
|
+
if (name === 'yes') {
|
|
945
|
+
FormEngine.closeDocument();
|
|
946
|
+
} else if (name === 'save') {
|
|
947
|
+
FormEngine.saveAndContinueToAddRecord(addRecordParams);
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
return false;
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Saves the current document, then (server-side, once the record this
|
|
955
|
+
* addRecordParams.originalUid placeholder was saved under is known)
|
|
956
|
+
* redirects on to Wizard/AddController for the field identified by
|
|
957
|
+
* addRecordParams, see FormAction::savedokaddrecord() /
|
|
958
|
+
* EditDocumentController::processData().
|
|
959
|
+
*
|
|
960
|
+
* @param {Object.<string, string>} addRecordParams
|
|
961
|
+
*/
|
|
962
|
+
FormEngine.saveAndContinueToAddRecord = function(addRecordParams: {[key: string]: string}): void {
|
|
963
|
+
Object.keys(addRecordParams).forEach((key: string): void => {
|
|
964
|
+
const input = document.createElement('input');
|
|
965
|
+
input.type = 'hidden';
|
|
966
|
+
input.name = 'addRecordAfterSave[' + key + ']';
|
|
967
|
+
input.value = addRecordParams[key];
|
|
968
|
+
FormEngine.formElement.append(input);
|
|
969
|
+
});
|
|
970
|
+
const actionInput = document.createElement('input');
|
|
971
|
+
actionInput.type = 'hidden';
|
|
972
|
+
actionInput.name = FormAction.saveAndAddRecord;
|
|
973
|
+
actionInput.value = '1';
|
|
974
|
+
FormEngine.formElement.append(actionInput);
|
|
975
|
+
// Submitted as a regular save on top: "_savedokaddrecord" is only understood by
|
|
976
|
+
// EditDocumentController, while other hosts of FormEngine forms (site configuration,
|
|
977
|
+
// constant editor, ...) test "_savedok" literally. Without it, such a form would
|
|
978
|
+
// neither save nor create anything and silently discard the submitted data.
|
|
979
|
+
submitFormWithAction(FormAction.save);
|
|
980
|
+
};
|
|
981
|
+
|
|
903
982
|
/**
|
|
904
983
|
* Show modal to confirm closing the document without saving
|
|
905
984
|
*/
|
|
@@ -151,6 +151,9 @@ export class LocalizationWizard extends LitElement {
|
|
|
151
151
|
|
|
152
152
|
private handleStepSummary(event: StepSummaryEvent) {
|
|
153
153
|
const recordInfo = this.recordInfoTask.value;
|
|
154
|
+
if (recordInfo === undefined) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
154
157
|
event.detail.summaryData = [
|
|
155
158
|
{
|
|
156
159
|
label: recordInfo.typeName,
|
|
@@ -45,23 +45,18 @@ export class HandlerSelectionStep implements WizardStepInterface, WizardStepValu
|
|
|
45
45
|
return [];
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return handlers;
|
|
61
|
-
} catch (error) {
|
|
62
|
-
console.warn('Failed to fetch handlers:', error);
|
|
63
|
-
return [];
|
|
64
|
-
}
|
|
48
|
+
const response = await new AjaxRequest(TYPO3.settings.ajaxUrls.wizard_localization_get_handlers)
|
|
49
|
+
.withQueryArguments({
|
|
50
|
+
recordType: recordType,
|
|
51
|
+
recordUid: recordUid,
|
|
52
|
+
sourceLanguage: sourceLanguage,
|
|
53
|
+
targetLanguage: targetLanguage,
|
|
54
|
+
mode: mode,
|
|
55
|
+
})
|
|
56
|
+
.get();
|
|
57
|
+
const handlers: LocalizationHandler[] = await response.resolve();
|
|
58
|
+
|
|
59
|
+
return handlers;
|
|
65
60
|
},
|
|
66
61
|
args: () => [this.context.recordType, this.context.recordUid, this.context.getStoreData('sourceLanguage'), this.context.getStoreData('targetLanguage'), this.context.getStoreData('localizationMode')],
|
|
67
62
|
autoRun: false
|
|
@@ -46,20 +46,15 @@ export class ModeStep implements WizardStepInterface, WizardStepValueInterface,
|
|
|
46
46
|
return [];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return modes;
|
|
59
|
-
} catch (error) {
|
|
60
|
-
console.warn('Failed to fetch localization modes:', error);
|
|
61
|
-
return [];
|
|
62
|
-
}
|
|
49
|
+
const response = await new AjaxRequest(TYPO3.settings.ajaxUrls.wizard_localization_get_modes).withQueryArguments({
|
|
50
|
+
recordType: recordType,
|
|
51
|
+
recordUid: recordUid,
|
|
52
|
+
targetLanguage: targetLanguage,
|
|
53
|
+
sourceLanguage: sourceLanguage,
|
|
54
|
+
}).get();
|
|
55
|
+
const modes: LocalizationMode[] = await response.resolve();
|
|
56
|
+
|
|
57
|
+
return modes;
|
|
63
58
|
},
|
|
64
59
|
args: () => [this.context.recordType, this.context.recordUid, this.context.getStoreData('targetLanguage'), this.context.getStoreData('sourceLanguage')],
|
|
65
60
|
autoRun: false
|
|
@@ -38,19 +38,14 @@ export class SourceLanguageStep implements WizardStepInterface, WizardStepValueI
|
|
|
38
38
|
return [];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return sourceLanguages;
|
|
50
|
-
} catch (error) {
|
|
51
|
-
console.warn('Failed to fetch source languages:', error);
|
|
52
|
-
return [];
|
|
53
|
-
}
|
|
41
|
+
const response = await new AjaxRequest(TYPO3.settings.ajaxUrls.wizard_localization_get_sources).withQueryArguments({
|
|
42
|
+
recordType: recordType,
|
|
43
|
+
recordUid: recordUid,
|
|
44
|
+
targetLanguage: targetLanguageId,
|
|
45
|
+
}).get();
|
|
46
|
+
const sourceLanguages: LocalizationLanguageRecord[] = await response.resolve();
|
|
47
|
+
|
|
48
|
+
return sourceLanguages;
|
|
54
49
|
},
|
|
55
50
|
args: () => [this.context.recordType, this.context.recordUid, this.context.getStoreData('targetLanguage')],
|
|
56
51
|
autoRun: false
|
|
@@ -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
|
}
|