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.
Files changed (29) 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/field-control/add-record.ts +27 -1
  8. package/backend/form-engine.ts +80 -1
  9. package/backend/localization/localization-wizard.ts +3 -0
  10. package/backend/localization/steps/handler-selection-step.ts +12 -17
  11. package/backend/localization/steps/mode-step.ts +9 -14
  12. package/backend/localization/steps/source-language-step.ts +8 -13
  13. package/backend/localization/steps/target-language-step.ts +12 -17
  14. package/backend/modal.ts +124 -1
  15. package/backend/tests/form-engine/element/extra/select-box-filter-test.ts +215 -0
  16. package/backend/tests/form-engine/element/select-tree-test.ts +121 -0
  17. package/backend/wizard/steps/finisher-step.ts +2 -2
  18. package/backend/wizard/wizard.ts +11 -4
  19. package/core/ajax/ajax-request.ts +18 -0
  20. package/core/tests/ajax/ajax-request-test.ts +1 -1
  21. package/core/tests/utility/text-normalizer-test.ts +138 -0
  22. package/core/utility/text-normalizer.ts +98 -0
  23. package/form/backend/form-editor/view-model.ts +1 -1
  24. package/form/backend/form-wizard/steps/settings-step.ts +6 -3
  25. package/package.json +2 -2
  26. package/workspaces/backend.ts +7 -2
  27. package/workspaces/renderable/comment-view.ts +4 -2
  28. package/workspaces/renderable/record-table.ts +41 -4
  29. package/workspaces/workspaces.ts +65 -12
package/.last-synced-tag CHANGED
@@ -1 +1 @@
1
- v14.3.6
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://my.typo3.org/about-mytypo3org/slack
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.6`
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
- // Escape filter text to prevent RegExp() exceptions
79
- const escapedFilterText = filterText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
+ // A plain substring match, no RegExp involved, therefore nothing to escape either
83
+ const foldedFilterText = TextNormalizer.foldCaseAndDiacritics(TextNormalizer.normalizeInvisibleCharacters(filterText));
80
84
 
81
- const matchFilter = new RegExp(escapedFilterText, 'i');
82
- this.availableOptions.forEach((option: HTMLOptionElement): void => {
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
- const regex = new RegExp(searchTerm, 'i');
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 (regex.test(node.name)) {
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
- FormEngine.preventFollowLinkIfNotSaved(this.controlElement.getAttribute('href'));
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
 
@@ -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
- FormEngine.closeModalsRecursive();
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
- try {
49
- const response = await new AjaxRequest(TYPO3.settings.ajaxUrls.wizard_localization_get_handlers)
50
- .withQueryArguments({
51
- recordType: recordType,
52
- recordUid: recordUid,
53
- sourceLanguage: sourceLanguage,
54
- targetLanguage: targetLanguage,
55
- mode: mode,
56
- })
57
- .get();
58
- const handlers: LocalizationHandler[] = await response.resolve();
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
- try {
50
- const response = await new AjaxRequest(TYPO3.settings.ajaxUrls.wizard_localization_get_modes).withQueryArguments({
51
- recordType: recordType,
52
- recordUid: recordUid,
53
- targetLanguage: targetLanguage,
54
- sourceLanguage: sourceLanguage,
55
- }).get();
56
- const modes: LocalizationMode[] = await response.resolve();
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
- try {
42
- const response = await new AjaxRequest(TYPO3.settings.ajaxUrls.wizard_localization_get_sources).withQueryArguments({
43
- recordType: recordType,
44
- recordUid: recordUid,
45
- targetLanguage: targetLanguageId,
46
- }).get();
47
- const sourceLanguages: LocalizationLanguageRecord[] = await response.resolve();
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
- 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
  }