typo3-typescript-sources 14.3.5 → 14.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.last-synced-tag +1 -1
  2. package/README-TYPO3.md +1 -1
  3. package/README.md +1 -1
  4. package/backend/element/contextual-record-edit-trigger.ts +9 -1
  5. package/backend/form-engine/element/extra/select-box-filter.ts +8 -5
  6. package/backend/form-engine/element/select-tree.ts +16 -2
  7. package/backend/form-engine/element/slug-element.ts +13 -5
  8. package/backend/form-engine/field-control/add-record.ts +27 -1
  9. package/backend/form-engine.ts +80 -1
  10. package/backend/layout-module/sticky-language-header.ts +186 -0
  11. package/backend/live-search/element/result/item/item.ts +2 -0
  12. package/backend/live-search/element/result/result-detail-container.ts +14 -1
  13. package/backend/localization/localization-wizard.ts +3 -0
  14. package/backend/localization/steps/handler-selection-step.ts +12 -17
  15. package/backend/localization/steps/mode-step.ts +9 -14
  16. package/backend/localization/steps/source-language-step.ts +8 -13
  17. package/backend/localization/steps/target-language-step.ts +12 -17
  18. package/backend/modal.ts +124 -1
  19. package/backend/module-docheader.ts +58 -0
  20. package/backend/page-wizard/steps/position-step.ts +5 -0
  21. package/backend/tests/form-engine/element/extra/select-box-filter-test.ts +215 -0
  22. package/backend/tests/form-engine/element/select-tree-test.ts +121 -0
  23. package/backend/tree/page-position-select.ts +25 -0
  24. package/backend/wizard/finisher/event-finisher.ts +62 -0
  25. package/backend/wizard/steps/finisher-step.ts +2 -2
  26. package/backend/wizard/wizard.ts +110 -7
  27. package/core/ajax/ajax-request.ts +18 -0
  28. package/core/tests/ajax/ajax-request-test.ts +1 -1
  29. package/core/tests/utility/text-normalizer-test.ts +138 -0
  30. package/core/utility/text-normalizer.ts +98 -0
  31. package/dashboard/dashboard-wizard.ts +86 -0
  32. package/dashboard/dashboard.ts +28 -76
  33. package/dashboard/wizard/finisher/dashboard-wizard-submission-service.ts +43 -0
  34. package/dashboard/wizard/steps/preset-step.ts +164 -0
  35. package/dashboard/wizard/steps/title-step.ts +90 -0
  36. package/form/backend/form-editor/core.ts +22 -9
  37. package/form/backend/form-editor/inspector-component.ts +61 -38
  38. package/form/backend/form-editor/stage-component.ts +2 -2
  39. package/form/backend/form-editor/view-model.ts +1 -1
  40. package/form/backend/form-wizard/form-wizard.ts +1 -0
  41. package/form/backend/form-wizard/steps/settings-step.ts +6 -13
  42. package/package.json +2 -2
  43. package/workspaces/backend.ts +7 -2
  44. package/workspaces/renderable/comment-view.ts +4 -2
  45. package/workspaces/renderable/record-table.ts +41 -4
  46. package/workspaces/workspaces.ts +65 -12
package/.last-synced-tag CHANGED
@@ -1 +1 @@
1
- v14.3.5
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.5`
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
  });
@@ -88,7 +88,7 @@ class SlugElement {
88
88
  }
89
89
 
90
90
  private registerEvents(): void {
91
- const fieldsToListenOnList = Object.values(this.getAvailableFieldsForProposalGeneration()).map((field: HTMLElement) => `[id="${field.id}"]`);
91
+ const fieldsToListenOnList = Object.values(this.getAvailableFieldsForProposalGeneration()).map((field: HTMLElement) => field.dataset.slugSelector);
92
92
  const recreateButton: HTMLButtonElement = this.fullElement.querySelector(Selectors.recreateButton);
93
93
 
94
94
  // Listen on 'listenerFieldNames' for new pages. This is typically the 'title' field
@@ -217,12 +217,20 @@ class SlugElement {
217
217
  private getAvailableFieldsForProposalGeneration(): { [key: string]: HTMLElement } {
218
218
  const availableFields: { [key: string]: HTMLElement } = {};
219
219
  for (const [fieldName, selector] of Object.entries(this.fieldsToListenOn)) {
220
- let field = document.querySelector('[data-formengine-input-name="' + selector + '"]') as HTMLElement;
221
- if (field === null) {
220
+ const selectorCandidates = [
221
+ '[data-formengine-input-name="' + selector + '"]',
222
222
  // Also check for fields, which do not point to a hidden input field (e.g. "input type=hidden", readOnly, or select fields)
223
- field = document.querySelector('[name="' + selector + '"]') as HTMLElement;
224
- }
223
+ '[name="' + selector + '"]'
224
+ ];
225
+ let field: HTMLElement;
226
+ let usedSelector = '';
227
+ selectorCandidates.some((testSelector: string) => {
228
+ field = document.querySelector(testSelector) as HTMLElement;
229
+ usedSelector = testSelector;
230
+ return field !== null;
231
+ });
225
232
  if (field !== null) {
233
+ field.dataset.slugSelector = usedSelector;
226
234
  availableFields[fieldName] = field;
227
235
  }
228
236
  }
@@ -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
  */
@@ -0,0 +1,186 @@
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 { css, html, LitElement, type TemplateResult } from 'lit';
15
+ import { customElement, query, state } from 'lit/decorators.js';
16
+ import RegularEvent from '@typo3/core/event/regular-event';
17
+
18
+ /**
19
+ * Module: @typo3/backend/layout-module/sticky-language-header
20
+ *
21
+ * Carries the header information of a language column twice: the default slot
22
+ * stays in the flow, the "sticky" slot holds a copy that is placed below the
23
+ * doc header, aligned with its column, for as long as the header itself is
24
+ * scrolled out of view. Without a "sticky" slot the element is inert.
25
+ */
26
+ @customElement('typo3-backend-page-layout-sticky-language-header')
27
+ export class PageLayoutStickyLanguageHeader extends LitElement {
28
+ static override styles = css`
29
+ /* the global border-box reset does not cross the shadow boundary */
30
+ :host,
31
+ *,
32
+ *::before,
33
+ *::after {
34
+ box-sizing: border-box;
35
+ }
36
+
37
+ :host {
38
+ display: block;
39
+ }
40
+
41
+ .sticky {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: .5rem;
45
+ position: fixed;
46
+ z-index: calc(var(--typo3-zindex-header) - 2);
47
+ padding: .5rem .75rem;
48
+ white-space: nowrap;
49
+ overflow: hidden;
50
+ pointer-events: none;
51
+ color: var(--typo3-component-color);
52
+ background-color: var(--typo3-component-bg);
53
+ border: var(--typo3-component-border-width) solid var(--typo3-component-border-color);
54
+ border-top: 0;
55
+ border-radius: 0 0 var(--pagemodule-grid-cell-border-radius) var(--pagemodule-grid-cell-border-radius);
56
+ box-shadow: var(--typo3-component-box-shadow-flyout);
57
+ opacity: 0;
58
+ /*
59
+ * The doc header is overlapped by a pixel: it ends on a fractional
60
+ * position, and two boxes meeting there round apart when they are
61
+ * rasterized, which lets the page show through the seam. Offset via top,
62
+ * a transform on a fixed element causes repaint artifacts.
63
+ */
64
+ top: calc(var(--sticky-top, 0px) - 1px - .5rem);
65
+ transition: opacity .2s ease-out, top .2s ease-out;
66
+ }
67
+
68
+ .sticky-active {
69
+ opacity: 1;
70
+ top: calc(var(--sticky-top, 0px) - 1px);
71
+ }
72
+
73
+ ::slotted([slot="sticky"]) {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: .5rem;
77
+ min-width: 0;
78
+ }
79
+
80
+ @media (prefers-reduced-motion: reduce) {
81
+ .sticky {
82
+ transition: none;
83
+ }
84
+ }
85
+ `;
86
+
87
+ // how far the box shadow reaches beyond the border box
88
+ private static readonly shadowBleed: number = 64;
89
+
90
+ @state() protected active: boolean = false;
91
+
92
+ @query('.inline') private readonly inline: HTMLElement;
93
+ @query('.sticky') private readonly sticky: HTMLElement;
94
+
95
+ private scrollEvent: RegularEvent | null = null;
96
+ private resizeEvent: RegularEvent | null = null;
97
+ private ticking: boolean = false;
98
+
99
+ public override connectedCallback(): void {
100
+ super.connectedCallback();
101
+ const sticky = this.querySelector(':scope > [slot="sticky"]');
102
+ if (sticky === null) {
103
+ return;
104
+ }
105
+ // the slot is rendered with [hidden] so it does not flash before the element
106
+ // is upgraded, and the global reset declares that one !important
107
+ sticky.removeAttribute('hidden');
108
+ this.scrollEvent = new RegularEvent('scroll', this.scheduleSync, true);
109
+ this.scrollEvent.bindTo(document);
110
+ this.resizeEvent = new RegularEvent('resize', this.scheduleSync);
111
+ this.resizeEvent.bindTo(window);
112
+ }
113
+
114
+ public override disconnectedCallback(): void {
115
+ this.scrollEvent?.release();
116
+ this.resizeEvent?.release();
117
+ this.scrollEvent = null;
118
+ this.resizeEvent = null;
119
+ super.disconnectedCallback();
120
+ }
121
+
122
+ protected override firstUpdated(): void {
123
+ this.scheduleSync();
124
+ }
125
+
126
+ protected override render(): TemplateResult {
127
+ return html`
128
+ <div class="inline"><slot></slot></div>
129
+ <div class="sticky${this.active ? ' sticky-active' : ''}" aria-hidden="true">
130
+ <slot name="sticky"></slot>
131
+ </div>`;
132
+ }
133
+
134
+ private readonly scheduleSync = (): void => {
135
+ if (this.ticking) {
136
+ return;
137
+ }
138
+ this.ticking = true;
139
+ requestAnimationFrame((): void => {
140
+ this.ticking = false;
141
+ this.sync();
142
+ });
143
+ };
144
+
145
+ private sync(): void {
146
+ const container = this.closest('.t3-grid-container');
147
+ if (container === null || !this.inline || !this.sticky) {
148
+ return;
149
+ }
150
+ // the lower edge of the doc header in viewport coordinates, which is where the
151
+ // element is placed: whichever of its bars still reaches into the viewport wins,
152
+ // so it holds for a missing button bar and for a bar that wrapped into two rows
153
+ const topOffset: number = Math.max(0, ...Array.from(
154
+ this.ownerDocument.querySelectorAll('.module-docheader'),
155
+ (bar: Element): number => bar.getBoundingClientRect().bottom,
156
+ ));
157
+ const containerRect = container.getBoundingClientRect();
158
+ const inlineRect = this.inline.getBoundingClientRect();
159
+ this.active = inlineRect.bottom <= topOffset && containerRect.bottom > topOffset;
160
+ if (!this.active) {
161
+ return;
162
+ }
163
+ this.sticky.style.setProperty('--sticky-top', topOffset + 'px');
164
+ this.sticky.style.left = inlineRect.left + 'px';
165
+ this.sticky.style.width = inlineRect.width + 'px';
166
+ const cutStart = Math.max(0, containerRect.left - inlineRect.left);
167
+ const cutEnd = Math.max(0, inlineRect.right - containerRect.right);
168
+ if (cutStart > 0 || cutEnd > 0) {
169
+ // clip-path takes the border box as its reference, so the region has to reach
170
+ // beyond it on every side that is not cut, or the box shadow is clipped away too
171
+ const bleed = -PageLayoutStickyLanguageHeader.shadowBleed + 'px';
172
+ const start = cutStart > 0 ? cutStart + 'px' : bleed;
173
+ const end = cutEnd > 0 ? cutEnd + 'px' : bleed;
174
+ this.sticky.style.clipPath = `inset(${bleed} ${end} ${bleed} ${start})`;
175
+ } else {
176
+ this.sticky.style.clipPath = '';
177
+ }
178
+ this.sticky.style.visibility = cutStart < inlineRect.width && cutEnd < inlineRect.width ? '' : 'hidden';
179
+ }
180
+ }
181
+
182
+ declare global {
183
+ interface HTMLElementTagNameMap {
184
+ 'typo3-backend-page-layout-sticky-language-header': PageLayoutStickyLanguageHeader;
185
+ }
186
+ }
@@ -27,8 +27,10 @@ export interface ResultItemInterface {
27
27
  defaultAction: ResultItemActionInterface;
28
28
  icon: Record<string, string>;
29
29
  language: ResultItemLanguageInterface | null;
30
+ thumbnailUrl: string | null;
30
31
  itemTitle: string;
31
32
  typeLabel: string;
33
+ properties: Record<string, string>;
32
34
  extraData: { [key: string]: any }
33
35
  }
34
36
 
@@ -32,12 +32,25 @@ export class ResultDetailContainer extends LitElement {
32
32
  return nothing;
33
33
  }
34
34
 
35
+ const propertyEntries = Object.entries(this.resultItem.properties ?? {});
36
+
35
37
  return html`
36
38
  <div class="livesearch-detail-preamble">
37
- <typo3-backend-icon identifier="${this.resultItem.icon.identifier}" overlay="${this.resultItem.icon.overlay}" size="large"></typo3-backend-icon>
39
+ ${this.resultItem.thumbnailUrl
40
+ ? html`<div class="livesearch-detail-preamble-thumbnail"><img src="${this.resultItem.thumbnailUrl}" loading="lazy" alt=""></div>`
41
+ : html`<typo3-backend-icon identifier="${this.resultItem.icon.identifier}" overlay="${this.resultItem.icon.overlay}" size="large"></typo3-backend-icon>`
42
+ }
38
43
  <h3>${this.resultItem.itemTitle}</h3>
39
44
  <p class="livesearch-detail-preamble-type">${this.resultItem.typeLabel}</p>
40
45
  </div>
46
+ ${propertyEntries.length > 0 ? html`
47
+ <dl class="livesearch-detail-properties">
48
+ ${propertyEntries.map(([label, value]: [string, string]) => html`
49
+ <dt>${label}</dt>
50
+ <dd>${value}</dd>
51
+ `)}
52
+ </dl>
53
+ ` : nothing}
41
54
  <typo3-backend-live-search-result-item-action-container .resultItem="${this.resultItem}"></typo3-backend-live-search-result-item-action-container>
42
55
  `;
43
56
  }
@@ -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