feat(dees-stepper): add configurable cancellation flow with confirmation modal
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-11 - 3.72.0 - feat(dees-stepper)
|
||||||
|
add configurable cancellation flow with confirmation modal
|
||||||
|
|
||||||
|
- adds a cancelable option to control whether steppers can be dismissed
|
||||||
|
- shows a confirmation modal when canceling via the new Cancel button or overlay backdrop
|
||||||
|
- updates footer button rendering and separators to support the new cancel action consistently
|
||||||
|
|
||||||
## 2026-04-11 - 3.71.1 - fix(dees-modal)
|
## 2026-04-11 - 3.71.1 - fix(dees-modal)
|
||||||
move modal content scrolling into dees-tile so long content stays scrollable with pinned header and actions
|
move modal content scrolling into dees-tile so long content stays scrollable with pinned header and actions
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-catalog',
|
name: '@design.estate/dees-catalog',
|
||||||
version: '3.71.1',
|
version: '3.72.0',
|
||||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { themeDefaultStyles } from '../../00theme.js';
|
|||||||
import { cssGeistFontFamily } from '../../00fonts.js';
|
import { cssGeistFontFamily } from '../../00fonts.js';
|
||||||
import { zIndexRegistry } from '../../00zindex.js';
|
import { zIndexRegistry } from '../../00zindex.js';
|
||||||
import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.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 type { DeesForm } from '../../00group-form/dees-form/dees-form.js';
|
||||||
import '../dees-tile/dees-tile.js';
|
import '../dees-tile/dees-tile.js';
|
||||||
|
|
||||||
@@ -45,15 +46,16 @@ export class DeesStepper extends DeesElement {
|
|||||||
|
|
||||||
public static async createAndShow(optionsArg: {
|
public static async createAndShow(optionsArg: {
|
||||||
steps: IStep[];
|
steps: IStep[];
|
||||||
|
cancelable?: boolean;
|
||||||
}): Promise<DeesStepper> {
|
}): Promise<DeesStepper> {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
const stepper = new DeesStepper();
|
const stepper = new DeesStepper();
|
||||||
stepper.steps = optionsArg.steps;
|
stepper.steps = optionsArg.steps;
|
||||||
stepper.overlay = true;
|
stepper.overlay = true;
|
||||||
|
if (optionsArg.cancelable !== undefined) {
|
||||||
|
stepper.cancelable = optionsArg.cancelable;
|
||||||
|
}
|
||||||
stepper.windowLayer = await DeesWindowLayer.createAndShow({ blur: true });
|
stepper.windowLayer = await DeesWindowLayer.createAndShow({ blur: true });
|
||||||
stepper.windowLayer.addEventListener('click', async () => {
|
|
||||||
await stepper.destroy();
|
|
||||||
});
|
|
||||||
body.append(stepper.windowLayer);
|
body.append(stepper.windowLayer);
|
||||||
body.append(stepper);
|
body.append(stepper);
|
||||||
|
|
||||||
@@ -81,6 +83,18 @@ export class DeesStepper extends DeesElement {
|
|||||||
})
|
})
|
||||||
accessor overlay: boolean = false;
|
accessor overlay: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true (default), the stepper renders a Cancel button in every step's
|
||||||
|
* footer, and clicking the backdrop (overlay mode) triggers the same cancel
|
||||||
|
* confirmation flow. Set to false for forced flows where the user must
|
||||||
|
* complete the stepper — no Cancel button, no backdrop dismissal.
|
||||||
|
*/
|
||||||
|
@property({
|
||||||
|
type: Boolean,
|
||||||
|
reflect: true,
|
||||||
|
})
|
||||||
|
accessor cancelable: boolean = true;
|
||||||
|
|
||||||
@property({ type: Number, attribute: false })
|
@property({ type: Number, attribute: false })
|
||||||
accessor stepperZIndex: number = 1000;
|
accessor stepperZIndex: number = 1000;
|
||||||
|
|
||||||
@@ -280,15 +294,17 @@ export class DeesStepper extends DeesElement {
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 1px solid var(--dees-color-border-subtle);
|
|
||||||
color: var(--dees-color-text-muted);
|
color: var(--dees-color-text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottomButtons .bottomButton:first-child {
|
/* Border-left separator on every button EXCEPT the first one.
|
||||||
border-left: none;
|
Uses general sibling so the stepHint (if rendered on the left) does
|
||||||
|
not shift which button counts as "first" and create a phantom border. */
|
||||||
|
.bottomButtons .bottomButton ~ .bottomButton {
|
||||||
|
border-left: 1px solid var(--dees-color-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottomButtons .bottomButton:hover {
|
.bottomButtons .bottomButton:hover {
|
||||||
@@ -347,6 +363,7 @@ export class DeesStepper extends DeesElement {
|
|||||||
<div
|
<div
|
||||||
class="stepperContainer ${this.overlay ? 'overlay' : ''}"
|
class="stepperContainer ${this.overlay ? 'overlay' : ''}"
|
||||||
style=${this.overlay ? `z-index: ${this.stepperZIndex};` : ''}
|
style=${this.overlay ? `z-index: ${this.stepperZIndex};` : ''}
|
||||||
|
@click=${this.handleOutsideClick}
|
||||||
>
|
>
|
||||||
${this.steps.map((stepArg, stepIndex) => {
|
${this.steps.map((stepArg, stepIndex) => {
|
||||||
const isSelected = stepArg === this.selectedStep;
|
const isSelected = stepArg === this.selectedStep;
|
||||||
@@ -370,12 +387,17 @@ export class DeesStepper extends DeesElement {
|
|||||||
<div class="title">${stepArg.title}</div>
|
<div class="title">${stepArg.title}</div>
|
||||||
<div class="content">${stepArg.content}</div>
|
<div class="content">${stepArg.content}</div>
|
||||||
</div>
|
</div>
|
||||||
${stepArg.menuOptions && stepArg.menuOptions.length > 0
|
<div slot="footer" class="bottomButtons">
|
||||||
? html`<div slot="footer" class="bottomButtons">
|
|
||||||
${isSelected && this.activeForm !== null && !this.activeFormValid
|
${isSelected && this.activeForm !== null && !this.activeFormValid
|
||||||
? html`<div class="stepHint">Complete form to continue</div>`
|
? html`<div class="stepHint">Complete form to continue</div>`
|
||||||
: ''}
|
: ''}
|
||||||
${stepArg.menuOptions.map((actionArg, actionIndex) => {
|
${this.cancelable
|
||||||
|
? html`<div
|
||||||
|
class="bottomButton"
|
||||||
|
@click=${() => this.handleCancelRequest()}
|
||||||
|
>Cancel</div>`
|
||||||
|
: ''}
|
||||||
|
${stepArg.menuOptions?.map((actionArg, actionIndex) => {
|
||||||
const isPrimary = actionIndex === stepArg.menuOptions!.length - 1;
|
const isPrimary = actionIndex === stepArg.menuOptions!.length - 1;
|
||||||
const isDisabled = isPrimary && this.activeForm !== null && !this.activeFormValid;
|
const isDisabled = isPrimary && this.activeForm !== null && !this.activeFormValid;
|
||||||
return html`
|
return html`
|
||||||
@@ -384,9 +406,8 @@ export class DeesStepper extends DeesElement {
|
|||||||
@click=${() => this.handleMenuOptionClick(actionArg, isPrimary)}
|
@click=${() => this.handleMenuOptionClick(actionArg, isPrimary)}
|
||||||
>${actionArg.name}</div>
|
>${actionArg.name}</div>
|
||||||
`;
|
`;
|
||||||
})}
|
}) ?? ''}
|
||||||
</div>`
|
</div>
|
||||||
: ''}
|
|
||||||
</dees-tile>`;
|
</dees-tile>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -556,6 +577,76 @@ export class DeesStepper extends DeesElement {
|
|||||||
await optionArg.action(this);
|
await optionArg.action(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently-open confirmation modal (if any). Prevents double-stacking when
|
||||||
|
* the user clicks the backdrop or the Cancel button while a confirm modal
|
||||||
|
* is already visible. The reference may become stale (point to a destroyed
|
||||||
|
* modal) if the user dismisses the confirm modal by clicking its own
|
||||||
|
* backdrop — so handleCancelRequest() uses isConnected to detect that.
|
||||||
|
*/
|
||||||
|
private cancelConfirmModal?: DeesModal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler on .stepperContainer. Mirrors dees-modal.handleOutsideClick:
|
||||||
|
* when the user clicks the empty backdrop area (target === stepperContainer,
|
||||||
|
* not any descendant tile), trigger the cancel confirmation flow. Clicks
|
||||||
|
* that originate inside a step tile have a different event.target and are
|
||||||
|
* ignored here.
|
||||||
|
*/
|
||||||
|
private handleOutsideClick(eventArg: MouseEvent) {
|
||||||
|
if (!this.overlay) return;
|
||||||
|
if (!this.cancelable) return;
|
||||||
|
eventArg.stopPropagation();
|
||||||
|
const stepperContainer = this.shadowRoot!.querySelector('.stepperContainer');
|
||||||
|
if (eventArg.target === stepperContainer) {
|
||||||
|
this.handleCancelRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown by both the backdrop click and the Cancel button in the footer.
|
||||||
|
* Presents a dees-modal asking the user to confirm cancellation. If they
|
||||||
|
* confirm, the stepper and window layer are destroyed; otherwise the
|
||||||
|
* confirm modal is dismissed and the stepper stays open.
|
||||||
|
*
|
||||||
|
* The isConnected check on the cached reference handles the case where the
|
||||||
|
* user dismissed the previous confirm modal by clicking ITS OWN backdrop —
|
||||||
|
* dees-modal.handleOutsideClick calls destroy() directly, bypassing our
|
||||||
|
* action callbacks, so our cached reference would be stale without this
|
||||||
|
* fallback check.
|
||||||
|
*/
|
||||||
|
public async handleCancelRequest() {
|
||||||
|
if (!this.cancelable) return;
|
||||||
|
if (this.cancelConfirmModal && this.cancelConfirmModal.isConnected) return;
|
||||||
|
this.cancelConfirmModal = undefined;
|
||||||
|
this.cancelConfirmModal = await DeesModal.createAndShow({
|
||||||
|
heading: 'Cancel setup?',
|
||||||
|
width: 'small',
|
||||||
|
content: html`
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Are you sure you want to cancel? Any progress on the current step will be lost.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Continue setup',
|
||||||
|
action: async (modal) => {
|
||||||
|
this.cancelConfirmModal = undefined;
|
||||||
|
await modal!.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Yes, cancel',
|
||||||
|
action: async (modal) => {
|
||||||
|
this.cancelConfirmModal = undefined;
|
||||||
|
await modal!.destroy();
|
||||||
|
await this.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async destroy() {
|
public async destroy() {
|
||||||
const domtools = await this.domtoolsPromise;
|
const domtools = await this.domtoolsPromise;
|
||||||
const container = this.shadowRoot!.querySelector('.stepperContainer');
|
const container = this.shadowRoot!.querySelector('.stepperContainer');
|
||||||
|
|||||||
Reference in New Issue
Block a user