@design.estate/dees-catalog 9.5.0 → 9.6.0

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.
@@ -4,9 +4,9 @@ import {
4
4
  DeesElement,
5
5
  customElement,
6
6
  html,
7
- css,
8
7
  cssManager,
9
8
  property,
9
+ state,
10
10
  type TemplateResult,
11
11
  } from '@design.estate/dees-element';
12
12
 
@@ -14,11 +14,13 @@ import * as domtools from '@design.estate/dees-domtools';
14
14
  import { stepperDemo } from './dees-stepper.demo.js';
15
15
  import { themeDefaultStyles } from '../../00theme.js';
16
16
  import { zIndexRegistry } from '../../00zindex.js';
17
- import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
18
17
  import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
19
18
  import type { DeesForm } from '../../00group-form/dees-form/dees-form.js';
20
19
  import '../../00group-feedback/dees-progressbar/dees-progressbar.js';
21
20
  import '../dees-tile/dees-tile.js';
21
+ import '../../00group-button/dees-button/dees-button.js';
22
+ import '../../00group-utility/dees-icon/dees-icon.js';
23
+ import { stepperStyles } from './styles.js';
22
24
 
23
25
  export interface IStepProgressState {
24
26
  label?: string;
@@ -36,6 +38,7 @@ export interface IStepProgress extends IStepProgressState {
36
38
 
37
39
  export interface IStep {
38
40
  title: string;
41
+ description?: string;
39
42
  content: TemplateResult;
40
43
  allowBack?: boolean;
41
44
  progressStep?: IStepProgress;
@@ -70,14 +73,12 @@ export class DeesStepper extends DeesElement {
70
73
  if (optionsArg.cancelable !== undefined) {
71
74
  stepper.cancelable = optionsArg.cancelable;
72
75
  }
73
- stepper.windowLayer = await DeesWindowLayer.createAndShow({ blur: true });
74
- body.append(stepper.windowLayer);
75
76
  body.append(stepper);
76
77
 
77
- // Get z-index for stepper (should be above window layer)
78
+ // Share overlay ordering with catalog controls opened from this dialog.
78
79
  stepper.stepperZIndex = zIndexRegistry.getNextZIndex();
79
80
  zIndexRegistry.register(stepper, stepper.stepperZIndex);
80
-
81
+ await stepper.updateComplete;
81
82
  return stepper;
82
83
  }
83
84
 
@@ -121,290 +122,46 @@ export class DeesStepper extends DeesElement {
121
122
 
122
123
  private activeFormSubscription?: { unsubscribe: () => void };
123
124
 
124
- private windowLayer?: DeesWindowLayer;
125
+ @state() private accessor stepError = '';
126
+ @state() private accessor progressRunning = false;
127
+ @state() private accessor actionPending = false;
128
+ private resizeObserver?: ResizeObserver;
129
+ private activeValidationStep?: IStep;
130
+ private actionStep?: IStep;
131
+ private destroyPromise?: Promise<void>;
132
+ private scrollTarget?: number;
133
+ private scrollInFlight = false;
134
+ private cancelRequestPending = false;
125
135
 
126
136
  constructor() {
127
137
  super();
138
+ this.registerStartupFunction(async (signal) => {
139
+ await this.updateComplete;
140
+ if (signal.aborted) return;
141
+ this.destroyPromise = undefined;
142
+ const dialog = this.shadowRoot?.querySelector('dialog');
143
+ if (dialog && !dialog.open) dialog.showModal();
144
+ this.resizeObserver = new ResizeObserver(() => this.positionSelectedStep(false));
145
+ this.resizeObserver.observe(this.shadowRoot!.querySelector('.stepperContainer')!);
146
+ for (const tile of this.shadowRoot!.querySelectorAll('.step')) this.resizeObserver.observe(tile);
147
+ await this.setScrollStatus();
148
+ });
128
149
  }
129
150
 
130
- public static styles = [
131
- themeDefaultStyles,
132
- cssManager.defaultStyles,
133
- css`
134
- /* TODO: Migrate hardcoded values to --dees-* CSS variables */
135
- :host {
136
- position: absolute;
137
- width: 100%;
138
- height: 100%;
139
- font-family: var(--dees-font-family);
140
- color: var(--dees-color-text-primary);
141
- }
142
-
143
- /*
144
- * In overlay mode the host is "transparent" to layout — the inner
145
- * .stepperContainer.overlay is what pins to the viewport and carries the
146
- * z-index. Keeping :host unpositioned avoids nesting the stacking context
147
- * under an auto-z-index parent (which was trapping .stepperContainer
148
- * below DeesWindowLayer's sibling layers). This mirrors how dees-modal
149
- * keeps its own :host unpositioned and lets .modalContainer drive layout.
150
- */
151
- :host([overlay]) {
152
- position: static;
153
- width: 0;
154
- height: 0;
155
- }
156
-
157
- .stepperContainer {
158
- position: absolute;
159
- width: 100%;
160
- height: 100%;
161
- overflow: hidden;
162
- }
163
-
164
- .stepperContainer.overlay {
165
- position: fixed;
166
- top: 0;
167
- left: 0;
168
- width: 100vw;
169
- height: 100vh;
170
- }
171
-
172
- /* Exit animation — reverse of the entry. Tiles slide DOWN + fade out,
173
- mirroring the .entrance slide-up. The transition override is needed
174
- because dees-tile.step has its own 0.7s transition for step selection
175
- which would otherwise make the exit sluggish. */
176
- .stepperContainer.predestroy dees-tile.step {
177
- transform: translateY(16px);
178
- filter: opacity(0);
179
- transition: transform 0.25s, filter 0.2s;
180
- }
181
-
182
- dees-tile.step {
183
- position: relative;
184
- pointer-events: none;
185
- box-sizing: border-box;
186
- width: min(500px, calc(100vw - 32px));
187
- max-width: 500px;
188
- max-height: calc(100vh - 64px);
189
- min-height: min(300px, calc(100vh - 64px));
190
- margin: auto;
191
- margin-bottom: 20px;
192
- filter: opacity(0.55) saturate(0.85);
193
- transition: transform 0.7s cubic-bezier(0.87, 0, 0.13, 1), filter 0.7s cubic-bezier(0.87, 0, 0.13, 1);
194
- user-select: none;
195
- }
196
-
197
- dees-tile.step.selected {
198
- pointer-events: all;
199
- filter: opacity(1) saturate(1);
200
- user-select: auto;
201
- }
202
-
203
- dees-tile.step.hiddenStep {
204
- filter: opacity(0);
205
- }
206
-
207
- dees-tile.step.entrance {
208
- transition: transform 0.35s ease, filter 0.35s ease;
209
- }
210
-
211
- dees-tile.step.entrance.hiddenStep {
212
- transform: translateY(16px);
213
- }
214
-
215
- dees-tile.step:last-child {
216
- margin-bottom: 100vh;
217
- }
218
-
219
- .stepperContainer.overlay dees-tile.step::part(outer) {
220
- box-shadow:
221
- 0 0 0 1px color-mix(in srgb, var(--dees-color-text-primary) 3%, transparent),
222
- var(--dees-shadow-lg);
223
- }
224
-
225
- .stepperContainer:not(.overlay) dees-tile.step {
226
- max-height: calc(100% - 64px);
227
- min-height: min(300px, calc(100% - 64px));
228
- }
229
-
230
- .step-header {
231
- height: 36px;
232
- display: flex;
233
- align-items: center;
234
- justify-content: space-between;
235
- padding: 0 8px 0 12px;
236
- gap: 12px;
237
- }
238
-
239
- .goBack-spacer {
240
- width: 1px;
241
- }
242
-
243
- .step-header .goBack {
244
- display: inline-flex;
245
- align-items: center;
246
- gap: 6px;
247
- height: 24px;
248
- padding: 0 8px;
249
- font-size: 12px;
250
- font-weight: 500;
251
- line-height: 1;
252
- border: none;
253
- background: transparent;
254
- color: var(--dees-color-text-muted);
255
- border-radius: var(--dees-radius-sm);
256
- cursor: pointer;
257
- transition:
258
- background-color var(--dees-transition-fast) var(--dees-ease-standard),
259
- color var(--dees-transition-fast) var(--dees-ease-standard),
260
- transform var(--dees-transition-default) var(--dees-ease-standard);
261
- }
262
-
263
- .step-header .goBack:hover {
264
- background: var(--dees-color-hover);
265
- color: var(--dees-color-text-secondary);
266
- transform: translateX(-2px);
267
- }
268
-
269
- .step-header .goBack:active {
270
- background: var(--dees-color-border-default);
271
- }
272
-
273
- .step-header .goBack span {
274
- transition: transform var(--dees-transition-default) var(--dees-ease-standard);
275
- display: inline-block;
276
- }
277
-
278
- .step-header .goBack:hover span {
279
- transform: translateX(-2px);
280
- }
281
-
282
- .step-header .stepCounter {
283
- color: var(--dees-color-text-muted);
284
- font-size: 12px;
285
- font-weight: 500;
286
- letter-spacing: -0.01em;
287
- padding: 0 8px;
288
- }
289
-
290
- .step-body .title {
291
- text-align: center;
292
- padding-top: 32px;
293
- font-family: var(--dees-font-family);
294
- font-size: 24px;
295
- font-weight: 600;
296
- letter-spacing: -0.01em;
297
- color: inherit;
298
- }
299
-
300
- .step-body .content {
301
- padding: 32px;
302
- }
303
-
304
- .step-body .content.withProgressStep {
305
- padding-top: 20px;
306
- }
307
-
308
- .progressStep {
309
- padding: 0 24px;
310
- }
311
-
312
- /* --- Footer: modal-style bottom buttons --- */
313
- .bottomButtons {
314
- display: flex;
315
- flex-direction: row;
316
- justify-content: flex-end;
317
- align-items: center;
318
- gap: 0;
319
- height: 36px;
320
- width: 100%;
321
- box-sizing: border-box;
322
- }
323
-
324
- .bottomButtons .bottomButton {
325
- padding: 0 16px;
326
- height: 100%;
327
- text-align: center;
328
- font-size: 12px;
329
- font-weight: 500;
330
- cursor: pointer;
331
- user-select: none;
332
- transition:
333
- background-color var(--dees-transition-fast) var(--dees-ease-standard),
334
- color var(--dees-transition-fast) var(--dees-ease-standard);
335
- background: transparent;
336
- border: none;
337
- color: var(--dees-color-text-muted);
338
- white-space: nowrap;
339
- display: flex;
340
- align-items: center;
341
- }
342
-
343
- /* Border-left separator on every button EXCEPT the first one.
344
- Uses general sibling so the stepHint (if rendered on the left) does
345
- not shift which button counts as "first" and create a phantom border. */
346
- .bottomButtons .bottomButton ~ .bottomButton {
347
- border-left: 1px solid var(--dees-color-border-subtle);
348
- }
349
-
350
- .bottomButtons .bottomButton:hover {
351
- background: var(--dees-color-hover);
352
- color: var(--dees-color-text-primary);
353
- }
354
-
355
- .bottomButtons .bottomButton:active {
356
- background: var(--dees-color-border-subtle);
357
- }
358
-
359
- .bottomButtons .bottomButton.primary {
360
- color: var(--dees-color-link);
361
- font-weight: 600;
362
- }
363
-
364
- .bottomButtons .bottomButton.primary:hover {
365
- background: color-mix(in srgb, var(--dees-color-link) 8%, transparent);
366
- color: color-mix(in srgb, var(--dees-color-link) 80%, var(--dees-color-text-primary));
367
- }
368
-
369
- .bottomButtons .bottomButton.primary:active {
370
- background: color-mix(in srgb, var(--dees-color-link) 12%, transparent);
371
- }
372
-
373
- .bottomButtons .bottomButton.disabled {
374
- pointer-events: none;
375
- opacity: 0.4;
376
- cursor: not-allowed;
377
- }
378
-
379
- .bottomButtons .bottomButton.disabled:hover {
380
- background: transparent;
381
- color: var(--dees-color-text-muted);
382
- }
383
-
384
- /* Hint shown on the left of the footer when the active step's form has
385
- unfilled required fields. Uses margin-right: auto to push right-aligned
386
- buttons to the right while keeping the hint flush-left. */
387
- .bottomButtons .stepHint {
388
- margin-right: auto;
389
- padding: 0 16px;
390
- font-size: 11px;
391
- line-height: 1;
392
- letter-spacing: -0.01em;
393
- color: var(--dees-color-text-muted);
394
- display: flex;
395
- align-items: center;
396
- user-select: none;
397
- }
398
- `,
399
- ];
151
+ public static styles = [themeDefaultStyles, cssManager.defaultStyles, stepperStyles];
400
152
 
401
153
  public render() {
154
+ const label = this.selectedStep?.title ?? 'Setup';
155
+ return this.overlay
156
+ ? html`<dialog class="stepperContainer overlay" aria-label=${label} aria-modal="true"
157
+ style=${`z-index: ${this.stepperZIndex};`} @cancel=${this.handleDialogCancel}
158
+ @click=${this.handleOutsideClick}>${this.renderSteps()}</dialog>`
159
+ : html`<div class="stepperContainer" role="region" aria-label=${label}>${this.renderSteps()}</div>`;
160
+ }
161
+
162
+ private renderSteps() {
163
+ if (!this.steps.length) return html`<div class="empty">No steps to show.</div>`;
402
164
  return html`
403
- <div
404
- class="stepperContainer ${this.overlay ? 'overlay' : ''}"
405
- style=${this.overlay ? `z-index: ${this.stepperZIndex};` : ''}
406
- @click=${this.handleOutsideClick}
407
- >
408
165
  ${this.steps.map((stepArg, stepIndex) => {
409
166
  const isSelected = stepArg === this.selectedStep;
410
167
  const isHidden =
@@ -413,21 +170,24 @@ export class DeesStepper extends DeesElement {
413
170
  const showBackButton = !isFirst && stepArg.allowBack !== false;
414
171
  const progressStepState = stepArg.progressStep ? this.getProgressStepState(stepArg) : null;
415
172
  return html`<dees-tile
416
- class="step ${isSelected ? 'selected' : ''} ${isHidden ? 'hiddenStep' : ''} ${isFirst ? 'entrance' : ''}"
417
- overscroll="contain"
173
+ class="step ${isSelected ? 'selected' : ''} ${isHidden ? 'hiddenStep' : ''}"
174
+ overscroll="contain" ?inert=${!isSelected} aria-hidden=${!isSelected}
418
175
  >
419
176
  <div slot="header" class="step-header">
420
- ${showBackButton
421
- ? html`<div class="goBack" @click=${this.goBack}>
422
- <span><-</span> go to previous step
423
- </div>`
424
- : html`<div class="goBack-spacer"></div>`}
425
- <div class="stepCounter">
426
- Step ${stepIndex + 1} of ${this.steps.length}
427
- </div>
177
+ <ol class="step-rail" aria-label="Progress">${this.steps.map((step, index) => html`
178
+ <li class="step-marker ${index < stepIndex ? 'complete' : index === stepIndex ? 'current' : ''}"
179
+ aria-label=${`${index + 1}. ${step.title}${index < stepIndex ? ', completed' : ''}`}
180
+ aria-current=${index === stepIndex ? 'step' : 'false'} title=${step.title}></li>
181
+ `)}</ol>
182
+ <span class="step-counter">${stepIndex + 1} of ${this.steps.length}</span>
428
183
  </div>
429
184
  <div class="step-body">
430
- <div class="title">${stepArg.title}</div>
185
+ <div class="heading-row">
186
+ <h2 class="title" tabindex="-1">${stepArg.title}</h2>
187
+ <span class="step-hint" aria-hidden=${!isSelected || this.activeForm === null || this.activeFormValid}
188
+ title="Fill in the required fields to continue.">Complete required fields</span>
189
+ </div>
190
+ ${stepArg.description ? html`<p class="description">${stepArg.description}</p>` : ''}
431
191
  ${stepArg.progressStep && progressStepState ? html`
432
192
  <div class="progressStep">
433
193
  <dees-progressbar
@@ -442,31 +202,32 @@ export class DeesStepper extends DeesElement {
442
202
  </div>
443
203
  ` : ''}
444
204
  <div class="content ${stepArg.progressStep ? 'withProgressStep' : ''}">${stepArg.content}</div>
205
+ ${isSelected && this.stepError ? html`<div class="step-error" role="alert">
206
+ <dees-icon icon="lucide:CircleAlert" iconSize="16"></dees-icon><span>${this.stepError}</span>
207
+ </div>` : ''}
445
208
  </div>
446
- <div slot="footer" class="bottomButtons">
447
- ${isSelected && this.activeForm !== null && !this.activeFormValid
448
- ? html`<div class="stepHint">Complete form to continue</div>`
449
- : ''}
209
+ <div slot="footer" class="step-footer">
210
+ <div class="bottomButtons">
211
+ ${showBackButton ? html`<dees-button class="back-action" type="ghost" text="Back"
212
+ icon="lucide:ChevronLeft" .disabled=${this.actionPending} @clicked=${this.goBack}></dees-button>`
213
+ : html`<span class="footer-spacer"></span>`}
450
214
  ${this.cancelable
451
- ? html`<div
452
- class="bottomButton"
453
- @click=${() => this.handleCancelRequest()}
454
- >Cancel</div>`
215
+ ? html`<dees-button type="ghost" text="Cancel" @clicked=${this.handleCancelRequest}></dees-button>`
455
216
  : ''}
217
+ ${isSelected && this.stepError && stepArg.validationFunc
218
+ ? html`<dees-button type="accent" text="Try again" icon="lucide:RotateCw" @clicked=${this.retryStep}></dees-button>` : ''}
456
219
  ${stepArg.menuOptions?.map((actionArg, actionIndex) => {
457
220
  const isPrimary = actionIndex === stepArg.menuOptions!.length - 1;
458
- const isDisabled = isPrimary && this.activeForm !== null && !this.activeFormValid;
459
- return html`
460
- <div
461
- class="bottomButton ${isPrimary ? 'primary' : ''} ${isDisabled ? 'disabled' : ''}"
462
- @click=${() => this.handleMenuOptionClick(actionArg, isPrimary)}
463
- >${actionArg.name}</div>
464
- `;
221
+ const isDisabled = this.actionPending || (isPrimary && (!this.activeFormValid || this.progressRunning || (!!this.stepError && !!stepArg.progressStep)));
222
+ return html`<dees-button class="step-action" .text=${actionArg.name}
223
+ .icon=${actionArg.iconName ?? ''} .type=${isPrimary ? 'accent' : 'secondary'}
224
+ .disabled=${isDisabled} .status=${isPrimary && this.actionPending ? 'pending' : 'normal'}
225
+ @clicked=${() => this.handleMenuOptionClick(actionArg, isPrimary)}></dees-button>`;
465
226
  }) ?? ''}
227
+ </div>
466
228
  </div>
467
229
  </dees-tile>`;
468
230
  })}
469
- </div>
470
231
  `;
471
232
  }
472
233
 
@@ -474,74 +235,88 @@ export class DeesStepper extends DeesElement {
474
235
  return this.steps.findIndex((stepArg2) => stepArg === stepArg2);
475
236
  };
476
237
 
477
- public async firstUpdated() {
478
- await this.domtoolsPromise;
479
- await this.domtools.convenience.smartdelay.delayFor(0);
480
- if (!this.steps.length) {
481
- return;
238
+ protected willUpdate(changed: Map<PropertyKey, unknown>) {
239
+ if (changed.has('steps') && !this.steps.includes(this.selectedStep)) this.selectedStep = this.steps[0];
240
+ if (changed.has('selectedStep') || changed.has('steps')) {
241
+ if (this.activeValidationStep !== this.selectedStep) {
242
+ this.activeValidationStep?.abortController?.abort();
243
+ if (this.activeValidationStep) this.activeValidationStep.validationFuncCalled = false;
244
+ this.activeValidationStep = this.selectedStep;
245
+ this.stepError = '';
246
+ this.progressRunning = false;
247
+ this.actionPending = false;
248
+ this.actionStep = undefined;
249
+ this.prepareStepForActivation(this.selectedStep);
250
+ }
482
251
  }
483
- this.prepareStepForActivation(this.steps[0]);
484
- this.selectedStep = this.steps[0];
485
- await this.updateComplete;
486
- await this.setScrollStatus();
487
- // Remove entrance class after initial animation completes
488
- await this.domtools.convenience.smartdelay.delayFor(350);
489
- this.shadowRoot!.querySelector('.step.entrance')?.classList.remove('entrance');
490
252
  }
491
253
 
492
254
  public async updated(changedProperties: Map<string | number | symbol, unknown>) {
493
- if (!changedProperties.has('selectedStep') && !changedProperties.has('steps')) {
494
- return;
255
+ if (changedProperties.has('overlay')) {
256
+ this.scroller?.destroy();
257
+ this.scroller = undefined!;
258
+ this.scrollTarget = undefined;
259
+ this.scrollInFlight = false;
495
260
  }
496
-
261
+ const dialog = this.shadowRoot?.querySelector('dialog');
262
+ if (dialog && !dialog.open && this.isConnected) dialog.showModal();
263
+ if (!changedProperties.has('selectedStep') && !changedProperties.has('steps') && !changedProperties.has('overlay')) return;
497
264
  await this.setScrollStatus();
265
+ if (!this.isConnected) return;
266
+ if (changedProperties.has('steps') || changedProperties.has('overlay')) {
267
+ this.resizeObserver?.disconnect();
268
+ this.resizeObserver?.observe(this.shadowRoot!.querySelector('.stepperContainer')!);
269
+ for (const tile of this.shadowRoot!.querySelectorAll('.step')) this.resizeObserver?.observe(tile);
270
+ }
271
+ if (this.overlay || this.shadowRoot?.activeElement) {
272
+ this.shadowRoot?.querySelector<HTMLElement>('.selected .title')?.focus({ preventScroll: true });
273
+ }
498
274
  }
499
275
 
500
276
  public scroller!: typeof domtools.plugins.SweetScroll.prototype;
501
277
 
502
278
  public async setScrollStatus() {
503
- const stepperContainer = this.shadowRoot!.querySelector('.stepperContainer') as HTMLElement;
504
- const selectedStepElement = this.shadowRoot!.querySelector('.selected') as HTMLElement;
505
- if (!selectedStepElement) {
506
- return;
507
- }
279
+ const step = this.selectedStep;
280
+ const selectedStepElement = this.shadowRoot?.querySelector<HTMLElement>('.step.selected');
281
+ if (!selectedStepElement || !this.isConnected) return;
282
+ const form = selectedStepElement.querySelector('dees-form');
283
+ await form?.updateComplete;
284
+ if (!this.isConnected || step !== this.selectedStep) return;
508
285
  this.scanActiveForm(selectedStepElement);
509
- if (!stepperContainer.style.paddingTop) {
510
- stepperContainer.style.paddingTop = `${
511
- stepperContainer.offsetHeight / 2 - selectedStepElement.offsetHeight / 2
512
- }px`;
286
+ this.positionSelectedStep(true);
287
+ if (!step.validationFuncCalled && step.validationFunc) {
288
+ step.abortController = new AbortController();
289
+ step.validationFuncCalled = true;
290
+ void this.runStepValidation(step, selectedStepElement, step.abortController.signal);
513
291
  }
514
- const scrollPosition =
515
- selectedStepElement.offsetTop -
516
- stepperContainer.offsetHeight / 2 +
517
- selectedStepElement.offsetHeight / 2;
518
- await domtools.DomTools.setupDomTools();
292
+ }
293
+
294
+ private positionSelectedStep(animate: boolean) {
295
+ const container = this.shadowRoot?.querySelector<HTMLElement>('.stepperContainer');
296
+ const selected = this.shadowRoot?.querySelector<HTMLElement>('.step.selected');
297
+ const first = this.shadowRoot?.querySelector<HTMLElement>('.step');
298
+ if (!container || !selected || !first || !this.isConnected) return;
299
+ container.style.paddingTop = `${Math.max(24, (container.clientHeight - first.offsetHeight) / 2)}px`;
300
+ const position = Math.max(0, selected.offsetTop - (container.clientHeight - selected.offsetHeight) / 2);
519
301
  if (!this.scroller) {
520
302
  this.scroller = new domtools.plugins.SweetScroll(
521
- {
522
- vertical: true,
523
- horizontal: false,
524
- easing: 'easeInOutExpo',
525
- duration: 700,
526
- },
527
- stepperContainer
528
- );
529
- }
530
- if (!this.selectedStep.validationFuncCalled && this.selectedStep.validationFunc) {
531
- this.selectedStep.abortController = new AbortController();
532
- this.selectedStep.validationFuncCalled = true;
533
- void this.runStepValidation(
534
- this.selectedStep,
535
- selectedStepElement,
536
- this.selectedStep.abortController.signal,
303
+ { vertical: true, horizontal: false, easing: 'easeOutCubic', duration: 320, cancellable: false },
304
+ container
537
305
  );
306
+ animate = false;
538
307
  }
539
- this.scroller.to(scrollPosition);
308
+ // Form validity can resize the footer after selection. Retarget the active
309
+ // scroll instead of letting ResizeObserver snap it to the destination.
310
+ if (this.scrollTarget !== undefined && Math.abs(this.scrollTarget - position) < 1) return;
311
+ const duration = (animate || this.scrollInFlight) && !matchMedia('(prefers-reduced-motion: reduce)').matches ? 320 : 0;
312
+ this.scrollTarget = position;
313
+ this.scroller.to(position, { duration, complete: () => { this.scrollInFlight = false; } });
314
+ this.scrollInFlight = duration > 0;
540
315
  }
541
316
 
542
317
  public async goBack() {
543
318
  const currentIndex = this.steps.findIndex((stepArg) => stepArg === this.selectedStep);
544
- if (currentIndex <= 0) {
319
+ if (currentIndex <= 0 || this.selectedStep.allowBack === false) {
545
320
  return;
546
321
  }
547
322
  // Abort any active listeners on current step
@@ -554,9 +329,11 @@ export class DeesStepper extends DeesElement {
554
329
  previousStep.validationFuncCalled = false;
555
330
  this.prepareStepForActivation(previousStep);
556
331
  this.selectedStep = previousStep;
557
- await this.domtoolsPromise;
558
- await this.domtools.convenience.smartdelay.delayFor(100);
559
- this.selectedStep.onReturnToStepFunc?.(this, this.shadowRoot!.querySelector('.selected') as HTMLElement);
332
+ await this.updateComplete;
333
+ if (this.isConnected && this.selectedStep === previousStep) {
334
+ try { await previousStep.onReturnToStepFunc?.(this, this.shadowRoot!.querySelector('.selected') as HTMLElement); }
335
+ catch (error) { if (this.selectedStep === previousStep) this.stepError = error instanceof Error ? error.message : 'Could not return to this step.'; }
336
+ }
560
337
  }
561
338
 
562
339
  public goNext() {
@@ -659,9 +436,11 @@ export class DeesStepper extends DeesElement {
659
436
  return;
660
437
  }
661
438
  const fields = form.getFormElements();
662
- this.activeFormValid = fields.every(
663
- (field) => !field.required || (field.value !== null && field.value !== undefined && field.value !== ''),
664
- );
439
+ this.activeFormValid = fields.every(field => {
440
+ if (!field.required || field.disabled) return true;
441
+ const value = 'getValue' in field && field.getValue ? field.getValue() : field.value;
442
+ return value !== null && value !== undefined && value !== '' && value !== false && (!Array.isArray(value) || value.length > 0);
443
+ });
665
444
  }
666
445
 
667
446
  /**
@@ -675,15 +454,23 @@ export class DeesStepper extends DeesElement {
675
454
  optionArg: plugins.tsclass.website.IMenuItem<DeesStepper>,
676
455
  isPrimary: boolean,
677
456
  ) {
457
+ if (this.actionPending || (isPrimary && this.progressRunning)) return;
458
+ this.recomputeFormValid();
678
459
  const form = this.activeForm;
679
- if (isPrimary && form) {
680
- if (!this.activeFormValid) return;
681
- await new Promise<void>((resolve) => {
682
- form.addEventListener('formData', () => resolve(), { once: true });
683
- form.gatherAndDispatch();
684
- });
460
+ if (isPrimary && !this.activeFormValid) return;
461
+ const step = this.selectedStep;
462
+ this.actionPending = true;
463
+ this.actionStep = step;
464
+ this.stepError = '';
465
+ try {
466
+ if (isPrimary && form) await form.gatherAndDispatch();
467
+ if (!this.isConnected || step !== this.selectedStep) return;
468
+ await optionArg.action(this);
469
+ } catch (error) {
470
+ if (this.isConnected && step === this.selectedStep) this.stepError = error instanceof Error ? error.message : 'The action could not be completed. Try again.';
471
+ } finally {
472
+ if (this.actionStep === step) { this.actionPending = false; this.actionStep = undefined; }
685
473
  }
686
- await optionArg.action(this);
687
474
  }
688
475
 
689
476
  private getProgressStepState(stepArg: IStep): IStepProgressState {
@@ -691,11 +478,7 @@ export class DeesStepper extends DeesElement {
691
478
  return {};
692
479
  }
693
480
 
694
- if (!stepArg.progressStepState) {
695
- stepArg.progressStepState = this.createInitialProgressStepState(stepArg);
696
- }
697
-
698
- return stepArg.progressStepState;
481
+ return stepArg.progressStepState ?? this.createInitialProgressStepState(stepArg);
699
482
  }
700
483
 
701
484
  private createInitialProgressStepState(stepArg: IStep): IStepProgressState {
@@ -723,10 +506,11 @@ export class DeesStepper extends DeesElement {
723
506
  selectedStepElement: HTMLElement,
724
507
  signal: AbortSignal,
725
508
  ): Promise<void> {
509
+ this.progressRunning = !!stepArg.progressStep;
726
510
  try {
727
511
  await stepArg.validationFunc?.(this, selectedStepElement, signal);
728
512
 
729
- if (signal.aborted) {
513
+ if (signal.aborted || !this.isConnected || this.selectedStep !== stepArg) {
730
514
  return;
731
515
  }
732
516
 
@@ -734,12 +518,13 @@ export class DeesStepper extends DeesElement {
734
518
  this.goNext();
735
519
  }
736
520
  } catch (error) {
737
- if (signal.aborted) {
521
+ if (signal.aborted || !this.isConnected || this.selectedStep !== stepArg) {
738
522
  return;
739
523
  }
740
524
 
525
+ const errorText = error instanceof Error ? error.message : 'This step could not be completed. Try again.';
526
+ this.stepError = errorText;
741
527
  if (stepArg.progressStep) {
742
- const errorText = error instanceof Error ? error.message : 'Unexpected error';
743
528
  this.appendProgressStepLine(`Error: ${errorText}`, stepArg);
744
529
  this.updateProgressStep(
745
530
  {
@@ -750,10 +535,27 @@ export class DeesStepper extends DeesElement {
750
535
  );
751
536
  }
752
537
 
753
- console.error(error);
538
+ } finally {
539
+ if (!signal.aborted && this.selectedStep === stepArg) this.progressRunning = false;
754
540
  }
755
541
  }
756
542
 
543
+ /** Rerun the current step after a recoverable validation/provisioning failure. */
544
+ public async retryStep() {
545
+ const step = this.selectedStep;
546
+ if (!step?.validationFunc || this.progressRunning || this.actionPending) return;
547
+ step.abortController?.abort();
548
+ step.validationFuncCalled = false;
549
+ this.stepError = '';
550
+ this.prepareStepForActivation(step);
551
+ await this.setScrollStatus();
552
+ }
553
+
554
+ private handleDialogCancel(event: Event) {
555
+ event.preventDefault();
556
+ void this.handleCancelRequest();
557
+ }
558
+
757
559
  /**
758
560
  * Currently-open confirmation modal (if any). Prevents double-stacking when
759
561
  * the user clicks the backdrop or the Cancel button while a confirm modal
@@ -793,10 +595,11 @@ export class DeesStepper extends DeesElement {
793
595
  * fallback check.
794
596
  */
795
597
  public async handleCancelRequest() {
796
- if (!this.cancelable) return;
598
+ if (!this.cancelable || this.cancelRequestPending || !this.isConnected) return;
797
599
  if (this.cancelConfirmModal && this.cancelConfirmModal.isConnected) return;
798
600
  this.cancelConfirmModal = undefined;
799
- this.cancelConfirmModal = await DeesModal.createAndShow({
601
+ this.cancelRequestPending = true;
602
+ try { this.cancelConfirmModal = await DeesModal.createAndShow({
800
603
  heading: 'Cancel setup?',
801
604
  width: 'small',
802
605
  content: html`
@@ -816,37 +619,50 @@ export class DeesStepper extends DeesElement {
816
619
  name: 'Yes, cancel',
817
620
  action: async (modal) => {
818
621
  this.cancelConfirmModal = undefined;
819
- modal!.destroy(); // fire-and-forget — starts the confirm modal fade
820
- const domtools = await this.domtoolsPromise;
821
- await domtools.convenience.smartdelay.delayFor(100);
622
+ await modal!.destroy();
822
623
  await this.destroy();
823
624
  },
824
625
  },
825
626
  ],
826
627
  });
628
+ if (!this.isConnected) await this.cancelConfirmModal.destroy();
629
+ } finally { this.cancelRequestPending = false; }
827
630
  }
828
631
 
829
- public async destroy() {
830
- const domtools = await this.domtoolsPromise;
831
- const container = this.shadowRoot!.querySelector('.stepperContainer');
832
- if (this.selectedStep?.abortController) {
833
- this.selectedStep.abortController.abort();
834
- }
835
- container?.classList.add('predestroy');
836
- await domtools.convenience.smartdelay.delayFor(250);
837
- if (this.parentElement) {
838
- this.parentElement.removeChild(this);
839
- }
840
- if (this.windowLayer) {
841
- await this.windowLayer.destroy();
842
- }
632
+ public destroy(): Promise<void> {
633
+ return this.destroyPromise ??= this.closeAndRemove();
634
+ }
843
635
 
844
- // Tear down form subscription to avoid leaks when the overlay closes.
636
+ private async closeAndRemove() {
637
+ this.selectedStep?.abortController?.abort();
638
+ // Close nested dialogs first so the browser can restore the original opener.
639
+ if (this.cancelConfirmModal?.isConnected) await this.cancelConfirmModal.destroy();
640
+ this.shadowRoot?.querySelector('.stepperContainer')?.classList.add('predestroy');
641
+ const tile = this.shadowRoot?.querySelector('.step.selected');
642
+ if (tile) await Promise.allSettled(tile.getAnimations().map(animation => animation.finished));
643
+ this.shadowRoot?.querySelector('dialog')?.close();
644
+ this.remove();
645
+ }
646
+
647
+ public async disconnectedCallback() {
648
+ this.selectedStep?.abortController?.abort();
649
+ if (this.selectedStep) this.selectedStep.validationFuncCalled = false;
650
+ this.resizeObserver?.disconnect();
651
+ this.resizeObserver = undefined;
652
+ this.scroller?.destroy();
653
+ this.scroller = undefined!;
654
+ this.scrollTarget = undefined;
655
+ this.scrollInFlight = false;
656
+ this.shadowRoot?.querySelector('dialog')?.close();
657
+ if (this.cancelConfirmModal?.isConnected) void this.cancelConfirmModal.destroy();
845
658
  this.activeFormSubscription?.unsubscribe();
846
659
  this.activeFormSubscription = undefined;
847
660
  this.activeForm = null;
848
-
849
- // Unregister from z-index registry
661
+ this.actionPending = false;
662
+ this.actionStep = undefined;
663
+ this.progressRunning = false;
850
664
  zIndexRegistry.unregister(this);
665
+ this.dispatchEvent(new CustomEvent('stepper-close', { bubbles: true, composed: true }));
666
+ await super.disconnectedCallback();
851
667
  }
852
668
  }