typo3-typescript-sources 14.3.5 → 14.3.6
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.md +1 -1
- package/backend/form-engine/element/slug-element.ts +13 -5
- package/backend/layout-module/sticky-language-header.ts +186 -0
- package/backend/live-search/element/result/item/item.ts +2 -0
- package/backend/live-search/element/result/result-detail-container.ts +14 -1
- package/backend/module-docheader.ts +58 -0
- package/backend/page-wizard/steps/position-step.ts +5 -0
- package/backend/tree/page-position-select.ts +25 -0
- package/backend/wizard/finisher/event-finisher.ts +62 -0
- package/backend/wizard/wizard.ts +99 -3
- package/dashboard/dashboard-wizard.ts +86 -0
- package/dashboard/dashboard.ts +28 -76
- package/dashboard/wizard/finisher/dashboard-wizard-submission-service.ts +43 -0
- package/dashboard/wizard/steps/preset-step.ts +164 -0
- package/dashboard/wizard/steps/title-step.ts +90 -0
- package/form/backend/form-editor/core.ts +22 -9
- package/form/backend/form-editor/inspector-component.ts +61 -38
- package/form/backend/form-editor/stage-component.ts +2 -2
- package/form/backend/form-wizard/form-wizard.ts +1 -0
- package/form/backend/form-wizard/steps/settings-step.ts +0 -10
- package/package.json +2 -2
package/.last-synced-tag
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
v14.3.
|
|
1
|
+
v14.3.6
|
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.6`
|
|
20
20
|
|
|
21
21
|
## 🌿 Branches
|
|
22
22
|
|
|
@@ -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) =>
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the TYPO3 CMS project.
|
|
3
|
+
*
|
|
4
|
+
* It is free software; you can redistribute it and/or modify it under
|
|
5
|
+
* the terms of the GNU General Public License, either version 2
|
|
6
|
+
* of the License, or any later version.
|
|
7
|
+
*
|
|
8
|
+
* For the full copyright and license information, please read the
|
|
9
|
+
* LICENSE.txt file that was distributed with this source code.
|
|
10
|
+
*
|
|
11
|
+
* The TYPO3 project - inspiring people to share!
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import DocumentService from '@typo3/core/document-service';
|
|
15
|
+
|
|
16
|
+
enum Selectors {
|
|
17
|
+
wrapper = '.module-docheader-wrapper',
|
|
18
|
+
navigation = '.t3js-module-docheader-navigation',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Module: @typo3/backend/module-docheader
|
|
23
|
+
*
|
|
24
|
+
* Publishes the measured docheader heights as CSS custom properties, because
|
|
25
|
+
* both bars wrap and the stylesheet cannot know how much of it stays pinned.
|
|
26
|
+
*/
|
|
27
|
+
class ModuleDocHeader {
|
|
28
|
+
private readonly resizeObserver: ResizeObserver;
|
|
29
|
+
private wrapper: HTMLElement | null = null;
|
|
30
|
+
private navigation: HTMLElement | null = null;
|
|
31
|
+
|
|
32
|
+
constructor() {
|
|
33
|
+
this.resizeObserver = new ResizeObserver((): void => this.publish());
|
|
34
|
+
DocumentService.ready().then((): void => this.observe());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private observe(): void {
|
|
38
|
+
this.wrapper = document.querySelector(Selectors.wrapper);
|
|
39
|
+
if (this.wrapper === null) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.navigation = this.wrapper.querySelector(Selectors.navigation);
|
|
44
|
+
// observing delivers an initial record, which is what publishes the first values
|
|
45
|
+
this.resizeObserver.observe(this.wrapper);
|
|
46
|
+
if (this.navigation !== null) {
|
|
47
|
+
this.resizeObserver.observe(this.navigation);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private publish(): void {
|
|
52
|
+
const style = document.documentElement.style;
|
|
53
|
+
style.setProperty('--module-docheader-height', `${this.wrapper.getBoundingClientRect().height}px`);
|
|
54
|
+
style.setProperty('--module-docheader-navigation-height', `${this.navigation?.getBoundingClientRect().height ?? 0}px`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default new ModuleDocHeader();
|
|
@@ -80,6 +80,7 @@ export class PositionStep implements WizardStepInterface, WizardStepValueInterfa
|
|
|
80
80
|
.activePageId="${currentValue?.pageUid}"
|
|
81
81
|
.insertPosition="${currentValue?.insertPosition}"
|
|
82
82
|
@typo3:page-position-select-tree:insert-position-change=${(event: CustomEvent) => this.handleInsertPositionChange(event)}
|
|
83
|
+
@typo3:page-position-select-tree:insert-position-confirm=${() => this.handleInsertPositionConfirm()}
|
|
83
84
|
>
|
|
84
85
|
</typo3-backend-component-page-position-select>
|
|
85
86
|
`;
|
|
@@ -141,6 +142,10 @@ export class PositionStep implements WizardStepInterface, WizardStepValueInterfa
|
|
|
141
142
|
});
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
private handleInsertPositionConfirm(): void {
|
|
146
|
+
this.context.dispatchAutoAdvance();
|
|
147
|
+
}
|
|
148
|
+
|
|
144
149
|
private initSummaryTask(): void {
|
|
145
150
|
this.summaryTask = new Task(this.context.wizard, {
|
|
146
151
|
task: async ([pageUid]: [number]): Promise<PageDetail> => {
|
|
@@ -56,6 +56,21 @@ export class InsertPositionChangeEvent extends CustomEvent<{pageUid: number, pos
|
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
export class InsertPositionConfirmEvent extends CustomEvent<{pageUid: number, position: Position}> {
|
|
61
|
+
static readonly eventName = 'typo3:page-position-select-tree:insert-position-confirm';
|
|
62
|
+
constructor(pageUid: number, position: Position) {
|
|
63
|
+
super(InsertPositionConfirmEvent.eventName, {
|
|
64
|
+
detail: {
|
|
65
|
+
pageUid: pageUid,
|
|
66
|
+
position: position,
|
|
67
|
+
},
|
|
68
|
+
bubbles: true,
|
|
69
|
+
composed: true
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
@customElement('typo3-backend-component-page-position-select-tree')
|
|
60
75
|
export class PagePositionSelectTree extends PageTree {
|
|
61
76
|
|
|
@@ -140,6 +155,16 @@ export class PagePositionSelect extends LitElement {
|
|
|
140
155
|
|
|
141
156
|
private async handleNodeSelected(event: CustomEvent): Promise<void> {
|
|
142
157
|
const selectedNode: TreeNodeInterface = event.detail.node;
|
|
158
|
+
|
|
159
|
+
// Re-selecting the active node confirms it and advances the wizard.
|
|
160
|
+
if (this.isActivePagePositionNode(selectedNode)) {
|
|
161
|
+
this.dispatchEvent(new InsertPositionConfirmEvent(
|
|
162
|
+
this.activePageId,
|
|
163
|
+
this.insertPosition
|
|
164
|
+
));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
143
168
|
const [pageUid, insertPosition] = selectedNode.identifier.split('-');
|
|
144
169
|
this.insertPosition = (insertPosition ?? 'inside') as Position;
|
|
145
170
|
this.activePageId = Number(pageUid);
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { html, type TemplateResult } from 'lit';
|
|
15
|
+
import type { FinisherInterface } from '@typo3/backend/wizard/finisher/finisher-interface';
|
|
16
|
+
import type { FinisherConfig } from '@typo3/backend/wizard/finisher/finisher-config';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Event finisher - dispatches the event supplied via `config.data.event` when
|
|
20
|
+
* the wizard finishes.
|
|
21
|
+
*
|
|
22
|
+
* The wizard runs in the top window while backend modules live in content
|
|
23
|
+
* frames, so the event is broadcast to the top document and every child frame,
|
|
24
|
+
* where the module that opened the wizard (e.g. the dashboard) can listen for
|
|
25
|
+
* it on its own document and update in place instead of reloading.
|
|
26
|
+
*/
|
|
27
|
+
export default class EventFinisher implements FinisherInterface {
|
|
28
|
+
private config!: FinisherConfig;
|
|
29
|
+
|
|
30
|
+
public setConfig(config: FinisherConfig): void {
|
|
31
|
+
this.config = config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async render(): Promise<TemplateResult> {
|
|
35
|
+
return html`
|
|
36
|
+
<typo3-backend-alert
|
|
37
|
+
severity="0"
|
|
38
|
+
heading="${this.config.labels.successTitle}"
|
|
39
|
+
message="${this.config.labels.successDescription}"
|
|
40
|
+
show-icon
|
|
41
|
+
></typo3-backend-alert>
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async execute(): Promise<void> {
|
|
46
|
+
const event = this.config.data.event;
|
|
47
|
+
|
|
48
|
+
if (!(event instanceof Event)) {
|
|
49
|
+
console.warn('Event finisher called without an event to dispatch');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
document.dispatchEvent(event);
|
|
54
|
+
for (let i = 0; i < window.frames.length; i++) {
|
|
55
|
+
try {
|
|
56
|
+
window.frames[i].document.dispatchEvent(event);
|
|
57
|
+
} catch {
|
|
58
|
+
// Cross-origin frame, skip
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/backend/wizard/wizard.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { html, LitElement, nothing, type PropertyValues, type TemplateResult } from 'lit';
|
|
15
15
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
16
16
|
import { classMap } from 'lit/directives/class-map.js';
|
|
17
|
+
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
|
18
|
+
import { KeyTypesEnum } from '@typo3/backend/enum/key-types';
|
|
17
19
|
import Modal from '@typo3/backend/modal';
|
|
18
20
|
import '@typo3/backend/element/alert-element';
|
|
19
21
|
import '@typo3/backend/element/icon-element';
|
|
@@ -41,6 +43,7 @@ type DataStoreKey = keyof DataStore;
|
|
|
41
43
|
export class Wizard extends LitElement {
|
|
42
44
|
@property({ type: Array, attribute: false }) steps: WizardStepInterface[] = [];
|
|
43
45
|
@property({ type: String, attribute: 'confirm-button-label' }) confirmButtonLabel: string;
|
|
46
|
+
@property({ type: Boolean, attribute: 'skip-summary' }) skipSummary: boolean = false;
|
|
44
47
|
@property({ type: Object, attribute: false }) submissionService: SubmissionServiceInterface;
|
|
45
48
|
|
|
46
49
|
@state() protected currentStepIndex: number = 0;
|
|
@@ -49,6 +52,8 @@ export class Wizard extends LitElement {
|
|
|
49
52
|
@state() protected allSteps: WizardStepInterface[] = [];
|
|
50
53
|
|
|
51
54
|
private progressTracker: ProgressTrackerElement | null = null;
|
|
55
|
+
private readonly primaryButtonRef: Ref<HTMLButtonElement> = createRef();
|
|
56
|
+
private focusPendingStep: WizardStepInterface | null = null;
|
|
52
57
|
|
|
53
58
|
override connectedCallback(): void {
|
|
54
59
|
super.connectedCallback();
|
|
@@ -144,7 +149,7 @@ export class Wizard extends LitElement {
|
|
|
144
149
|
return html`
|
|
145
150
|
<div class="wizard">
|
|
146
151
|
<div class="wizard-progress">${this.renderProgressTracker()}</div>
|
|
147
|
-
<div class="wizard-content" aria-live="polite">${this.currentStep?.render()}</div>
|
|
152
|
+
<div class="wizard-content" aria-live="polite" @keydown=${this.handleContentKeydown}>${this.currentStep?.render()}</div>
|
|
148
153
|
<div class="wizard-actions">${this.renderWizardButtons()}</div>
|
|
149
154
|
</div>
|
|
150
155
|
`;
|
|
@@ -177,7 +182,7 @@ export class Wizard extends LitElement {
|
|
|
177
182
|
) {
|
|
178
183
|
this.allSteps = [
|
|
179
184
|
...this.steps,
|
|
180
|
-
new ConfirmStep(this),
|
|
185
|
+
...(this.skipSummary ? [] : [new ConfirmStep(this)]),
|
|
181
186
|
new FinisherStep(this, this.submissionService),
|
|
182
187
|
];
|
|
183
188
|
|
|
@@ -188,13 +193,87 @@ export class Wizard extends LitElement {
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
if (this.currentStep && _changedProperties.has('currentStep')) {
|
|
196
|
+
// Optional per-step readiness hook (e.g. reinitialising FormEngine).
|
|
191
197
|
if (this.hasAfterRender(this.currentStep)) {
|
|
192
198
|
await this.currentStep.afterRender();
|
|
193
199
|
this.requestUpdate();
|
|
194
200
|
}
|
|
201
|
+
this.focusPendingStep = this.currentStep;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await this.moveFocusIntoStep();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Move focus into the current step's content once present. Async steps (e.g.
|
|
209
|
+
* @lit/task) re-render the wizard when their data settles, so retry on every
|
|
210
|
+
* update until a focus target is found — no per-step focus wiring required.
|
|
211
|
+
*/
|
|
212
|
+
private async moveFocusIntoStep(): Promise<void> {
|
|
213
|
+
const step = this.focusPendingStep;
|
|
214
|
+
if (!step || step !== this.currentStep) {
|
|
215
|
+
this.focusPendingStep = null; // no request, or navigated on since
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
await this.updateComplete;
|
|
219
|
+
if (step === this.currentStep && this.focusStepContent()) {
|
|
220
|
+
this.focusPendingStep = null;
|
|
195
221
|
}
|
|
196
222
|
}
|
|
197
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Focus the selected radio, otherwise the first form control. Returns whether
|
|
226
|
+
* focus settled on the step's own content; the primary-button fall back
|
|
227
|
+
* returns `false` so async content can still claim focus once it renders.
|
|
228
|
+
*/
|
|
229
|
+
private focusStepContent(): boolean {
|
|
230
|
+
const content = this.renderRoot.querySelector('.wizard-content');
|
|
231
|
+
if (content) {
|
|
232
|
+
// Don't steal focus if the step already has a focus.
|
|
233
|
+
if (content.contains(this.ownerDocument.activeElement)) {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const target = this.findFocusTarget(content);
|
|
238
|
+
if (target) {
|
|
239
|
+
// autofocus makes an opening modal <dialog> pick this over the close
|
|
240
|
+
// button; focus() covers later step transitions.
|
|
241
|
+
target.setAttribute('autofocus', '');
|
|
242
|
+
target.focus();
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// No focusable content (yet): fall back to the primary button, but not if
|
|
248
|
+
// focus already rests on an action button (don't steal it on re-renders).
|
|
249
|
+
const actions = this.renderRoot.querySelector('.wizard-actions');
|
|
250
|
+
if (!actions?.contains(this.ownerDocument.activeElement)) {
|
|
251
|
+
this.primaryButtonRef.value?.focus();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Explicit autofocus wins, then the selected radio, then the first control
|
|
259
|
+
* that is enabled and visible. Shadow roots are not pierced — custom
|
|
260
|
+
* elements take part by declaring autofocus or tabindex="0" on their host.
|
|
261
|
+
*/
|
|
262
|
+
private findFocusTarget(content: Element): HTMLElement | null {
|
|
263
|
+
const firstOperable = (selector: string): HTMLElement | null => {
|
|
264
|
+
for (const element of content.querySelectorAll<HTMLElement>(selector)) {
|
|
265
|
+
if (!element.matches(':disabled') && element.checkVisibility()) {
|
|
266
|
+
return element;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return firstOperable('[autofocus]')
|
|
273
|
+
?? firstOperable('input[type="radio"]:checked')
|
|
274
|
+
?? firstOperable('input:not([type="hidden"]):not([type="radio"]):not([readonly]), select, button, textarea:not([readonly]), [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])');
|
|
275
|
+
}
|
|
276
|
+
|
|
198
277
|
private hasAfterRender(step: WizardStepInterface): step is WizardStepInterface & WizardStepAfterRenderInterface {
|
|
199
278
|
return step && typeof (step as any).afterRender === 'function';
|
|
200
279
|
}
|
|
@@ -211,6 +290,23 @@ export class Wizard extends LitElement {
|
|
|
211
290
|
this.tryAutoAdvance();
|
|
212
291
|
};
|
|
213
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Enter inside a bare input advances the wizard, emulating a form submit.
|
|
295
|
+
* Controls with their own Enter behaviour are left alone: inputs inside a
|
|
296
|
+
* <form>, search inputs, checkboxes and non-input controls.
|
|
297
|
+
*/
|
|
298
|
+
private readonly handleContentKeydown = (event: KeyboardEvent): void => {
|
|
299
|
+
if (event.key !== KeyTypesEnum.ENTER) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const target = event.target;
|
|
303
|
+
if (!(target instanceof HTMLInputElement) || target.form || target.type === 'search' || target.type === 'checkbox') {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
void this.handleNext();
|
|
308
|
+
};
|
|
309
|
+
|
|
214
310
|
private renderWizardButtons(): TemplateResult {
|
|
215
311
|
return html`
|
|
216
312
|
${this.renderPreviousButton()}
|
|
@@ -261,7 +357,7 @@ export class Wizard extends LitElement {
|
|
|
261
357
|
return html`
|
|
262
358
|
<div>
|
|
263
359
|
${resetButtonHtml}
|
|
264
|
-
<button type="button" class="${classMap({ 'btn': true, 'btn-primary': true, 'disabled': !isComplete })}"
|
|
360
|
+
<button type="button" ${ref(this.primaryButtonRef)} class="${classMap({ 'btn': true, 'btn-primary': true, 'disabled': !isComplete })}"
|
|
265
361
|
@click="${this.handleNext}"
|
|
266
362
|
>
|
|
267
363
|
${buttonLabel}
|