feat(stepper,updater): add progress-aware stepper flows and updater countdown states

This commit is contained in:
2026-04-16 14:21:16 +00:00
parent 2f4c47f0d2
commit 428d2741d1
7 changed files with 806 additions and 141 deletions
@@ -5,8 +5,6 @@ import {
customElement,
html,
css,
unsafeCSS,
type CSSResult,
cssManager,
property,
type TemplateResult,
@@ -20,16 +18,33 @@ import { zIndexRegistry } from '../../00zindex.js';
import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
import type { DeesForm } from '../../00group-form/dees-form/dees-form.js';
import '../../00group-feedback/dees-progressbar/dees-progressbar.js';
import '../dees-tile/dees-tile.js';
export interface IStepProgressState {
label?: string;
percentage?: number;
indeterminate?: boolean;
showPercentage?: boolean;
statusText?: string;
terminalLines?: string[];
statusRows?: number;
}
export interface IStepProgress extends IStepProgressState {
autoAdvance?: boolean;
}
export interface IStep {
title: string;
content: TemplateResult;
progressStep?: IStepProgress;
menuOptions?: plugins.tsclass.website.IMenuItem<DeesStepper>[];
validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise<any>;
onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise<any>;
validationFuncCalled?: boolean;
abortController?: AbortController;
progressStepState?: IStepProgressState;
}
declare global {
@@ -276,6 +291,14 @@ export class DeesStepper extends DeesElement {
padding: 32px;
}
.step-body .content.withProgressStep {
padding-top: 20px;
}
.progressStep {
padding: 0 24px;
}
/* --- Footer: modal-style bottom buttons --- */
.bottomButtons {
display: flex;
@@ -375,6 +398,7 @@ export class DeesStepper extends DeesElement {
const isHidden =
this.getIndexOfStep(stepArg) > this.getIndexOfStep(this.selectedStep);
const isFirst = stepIndex === 0;
const progressStepState = stepArg.progressStep ? this.getProgressStepState(stepArg) : null;
return html`<dees-tile
class="step ${isSelected ? 'selected' : ''} ${isHidden ? 'hiddenStep' : ''} ${isFirst ? 'entrance' : ''}"
>
@@ -390,7 +414,20 @@ export class DeesStepper extends DeesElement {
</div>
<div class="step-body">
<div class="title">${stepArg.title}</div>
<div class="content">${stepArg.content}</div>
${stepArg.progressStep && progressStepState ? html`
<div class="progressStep">
<dees-progressbar
.label=${progressStepState.label ?? stepArg.title}
.percentage=${progressStepState.percentage ?? 0}
.indeterminate=${progressStepState.indeterminate ?? false}
.showPercentage=${progressStepState.showPercentage ?? true}
.statusText=${progressStepState.statusText ?? ''}
.terminalLines=${progressStepState.terminalLines ?? []}
.statusRows=${progressStepState.statusRows ?? 3}
></dees-progressbar>
</div>
` : ''}
<div class="content ${stepArg.progressStep ? 'withProgressStep' : ''}">${stepArg.content}</div>
</div>
<div slot="footer" class="bottomButtons">
${isSelected && this.activeForm !== null && !this.activeFormValid
@@ -426,22 +463,30 @@ export class DeesStepper extends DeesElement {
public async firstUpdated() {
await this.domtoolsPromise;
await this.domtools.convenience.smartdelay.delayFor(0);
if (!this.steps.length) {
return;
}
this.prepareStepForActivation(this.steps[0]);
this.selectedStep = this.steps[0];
this.setScrollStatus();
await this.updateComplete;
await this.setScrollStatus();
// Remove entrance class after initial animation completes
await this.domtools.convenience.smartdelay.delayFor(350);
this.shadowRoot!.querySelector('.step.entrance')?.classList.remove('entrance');
}
public async updated() {
this.setScrollStatus();
public async updated(changedProperties: Map<string | number | symbol, unknown>) {
if (!changedProperties.has('selectedStep') && !changedProperties.has('steps')) {
return;
}
await this.setScrollStatus();
}
public scroller!: typeof domtools.plugins.SweetScroll.prototype;
public async setScrollStatus() {
const stepperContainer = this.shadowRoot!.querySelector('.stepperContainer') as HTMLElement;
const firstStepElement = this.shadowRoot!.querySelector('.step') as HTMLElement;
const selectedStepElement = this.shadowRoot!.querySelector('.selected') as HTMLElement;
if (!selectedStepElement) {
return;
@@ -452,14 +497,11 @@ export class DeesStepper extends DeesElement {
stepperContainer.offsetHeight / 2 - selectedStepElement.offsetHeight / 2
}px`;
}
console.log('Setting scroll status');
console.log(selectedStepElement);
const scrollPosition =
selectedStepElement.offsetTop -
stepperContainer.offsetHeight / 2 +
selectedStepElement.offsetHeight / 2;
console.log(scrollPosition);
const domtoolsInstance = await domtools.DomTools.setupDomTools();
await domtools.DomTools.setupDomTools();
if (!this.scroller) {
this.scroller = new domtools.plugins.SweetScroll(
{
@@ -474,7 +516,11 @@ export class DeesStepper extends DeesElement {
if (!this.selectedStep.validationFuncCalled && this.selectedStep.validationFunc) {
this.selectedStep.abortController = new AbortController();
this.selectedStep.validationFuncCalled = true;
await this.selectedStep.validationFunc(this, selectedStepElement, this.selectedStep.abortController.signal);
void this.runStepValidation(
this.selectedStep,
selectedStepElement,
this.selectedStep.abortController.signal,
);
}
this.scroller.to(scrollPosition);
}
@@ -492,6 +538,7 @@ export class DeesStepper extends DeesElement {
currentStep.validationFuncCalled = false;
const previousStep = this.steps[currentIndex - 1];
previousStep.validationFuncCalled = false;
this.prepareStepForActivation(previousStep);
this.selectedStep = previousStep;
await this.domtoolsPromise;
await this.domtools.convenience.smartdelay.delayFor(100);
@@ -511,9 +558,52 @@ export class DeesStepper extends DeesElement {
currentStep.validationFuncCalled = false;
const nextStep = this.steps[currentIndex + 1];
nextStep.validationFuncCalled = false;
this.prepareStepForActivation(nextStep);
this.selectedStep = nextStep;
}
public resetProgressStep(stepArg: IStep = this.selectedStep) {
if (!stepArg?.progressStep) {
return;
}
stepArg.progressStepState = this.createInitialProgressStepState(stepArg);
this.requestUpdate();
}
public updateProgressStep(
progressStateArg: Partial<IStepProgressState>,
stepArg: IStep = this.selectedStep,
) {
if (!stepArg?.progressStep) {
return;
}
const currentProgressState = this.getProgressStepState(stepArg);
stepArg.progressStepState = {
...currentProgressState,
...progressStateArg,
terminalLines: progressStateArg.terminalLines
? [...progressStateArg.terminalLines]
: [...(currentProgressState.terminalLines ?? [])],
};
this.requestUpdate();
}
public appendProgressStepLine(lineArg: string, stepArg: IStep = this.selectedStep) {
if (!stepArg?.progressStep) {
return;
}
const currentProgressState = this.getProgressStepState(stepArg);
this.updateProgressStep(
{
terminalLines: [...(currentProgressState.terminalLines ?? []), lineArg],
},
stepArg,
);
}
/**
* Scans the currently selected step for a <dees-form> in its content. When
* found, subscribes to the form's RxJS changeSubject so the primary
@@ -582,6 +672,74 @@ export class DeesStepper extends DeesElement {
await optionArg.action(this);
}
private getProgressStepState(stepArg: IStep): IStepProgressState {
if (!stepArg.progressStep) {
return {};
}
if (!stepArg.progressStepState) {
stepArg.progressStepState = this.createInitialProgressStepState(stepArg);
}
return stepArg.progressStepState;
}
private createInitialProgressStepState(stepArg: IStep): IStepProgressState {
return {
label: stepArg.progressStep?.label ?? stepArg.title,
percentage: stepArg.progressStep?.percentage ?? 0,
indeterminate: stepArg.progressStep?.indeterminate ?? false,
showPercentage: stepArg.progressStep?.showPercentage ?? true,
statusText: stepArg.progressStep?.statusText ?? '',
terminalLines: [...(stepArg.progressStep?.terminalLines ?? [])],
statusRows: stepArg.progressStep?.statusRows ?? 3,
};
}
private prepareStepForActivation(stepArg?: IStep) {
if (!stepArg?.progressStep) {
return;
}
stepArg.progressStepState = this.createInitialProgressStepState(stepArg);
}
private async runStepValidation(
stepArg: IStep,
selectedStepElement: HTMLElement,
signal: AbortSignal,
): Promise<void> {
try {
await stepArg.validationFunc?.(this, selectedStepElement, signal);
if (signal.aborted) {
return;
}
if (stepArg.progressStep && stepArg.progressStep.autoAdvance !== false && this.selectedStep === stepArg) {
this.goNext();
}
} catch (error) {
if (signal.aborted) {
return;
}
if (stepArg.progressStep) {
const errorText = error instanceof Error ? error.message : 'Unexpected error';
this.appendProgressStepLine(`Error: ${errorText}`, stepArg);
this.updateProgressStep(
{
indeterminate: false,
statusText: errorText,
},
stepArg,
);
}
console.error(error);
}
}
/**
* Currently-open confirmation modal (if any). Prevents double-stacking when
* the user clicks the backdrop or the Cancel button while a confirm modal
@@ -657,6 +815,9 @@ export class DeesStepper extends DeesElement {
public async destroy() {
const domtools = await this.domtoolsPromise;
const container = this.shadowRoot!.querySelector('.stepperContainer');
if (this.selectedStep?.abortController) {
this.selectedStep.abortController.abort();
}
container?.classList.add('predestroy');
await domtools.convenience.smartdelay.delayFor(250);
if (this.parentElement) {