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 CHANGED
@@ -1 +1 @@
1
- v14.3.5
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.5`
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) => `[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
  }
@@ -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
  }
@@ -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
+ }
@@ -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}