diff --git a/changelog.md b/changelog.md index 27e5d2f..dee4805 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # 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) move modal content scrolling into dees-tile so long content stays scrollable with pinned header and actions diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 3b86e4f..1cc816b 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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.' } diff --git a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts index e7dbc80..87b4b1b 100644 --- a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts +++ b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts @@ -18,6 +18,7 @@ import { themeDefaultStyles } from '../../00theme.js'; import { cssGeistFontFamily } from '../../00fonts.js'; 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 '../dees-tile/dees-tile.js'; @@ -45,15 +46,16 @@ export class DeesStepper extends DeesElement { public static async createAndShow(optionsArg: { steps: IStep[]; + cancelable?: boolean; }): Promise { const body = document.body; const stepper = new DeesStepper(); stepper.steps = optionsArg.steps; stepper.overlay = true; + if (optionsArg.cancelable !== undefined) { + stepper.cancelable = optionsArg.cancelable; + } stepper.windowLayer = await DeesWindowLayer.createAndShow({ blur: true }); - stepper.windowLayer.addEventListener('click', async () => { - await stepper.destroy(); - }); body.append(stepper.windowLayer); body.append(stepper); @@ -81,6 +83,18 @@ export class DeesStepper extends DeesElement { }) 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 }) accessor stepperZIndex: number = 1000; @@ -280,15 +294,17 @@ export class DeesStepper extends DeesElement { transition: all 0.15s ease; background: transparent; border: none; - border-left: 1px solid var(--dees-color-border-subtle); color: var(--dees-color-text-muted); white-space: nowrap; display: flex; align-items: center; } - .bottomButtons .bottomButton:first-child { - border-left: none; + /* Border-left separator on every button EXCEPT the first one. + 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 { @@ -347,6 +363,7 @@ export class DeesStepper extends DeesElement {
${this.steps.map((stepArg, stepIndex) => { const isSelected = stepArg === this.selectedStep; @@ -370,23 +387,27 @@ export class DeesStepper extends DeesElement {
${stepArg.title}
${stepArg.content}
- ${stepArg.menuOptions && stepArg.menuOptions.length > 0 - ? html`
- ${isSelected && this.activeForm !== null && !this.activeFormValid - ? html`
Complete form to continue
` - : ''} - ${stepArg.menuOptions.map((actionArg, actionIndex) => { - const isPrimary = actionIndex === stepArg.menuOptions!.length - 1; - const isDisabled = isPrimary && this.activeForm !== null && !this.activeFormValid; - return html` -
this.handleMenuOptionClick(actionArg, isPrimary)} - >${actionArg.name}
- `; - })} -
` - : ''} +
+ ${isSelected && this.activeForm !== null && !this.activeFormValid + ? html`
Complete form to continue
` + : ''} + ${this.cancelable + ? html`
this.handleCancelRequest()} + >Cancel
` + : ''} + ${stepArg.menuOptions?.map((actionArg, actionIndex) => { + const isPrimary = actionIndex === stepArg.menuOptions!.length - 1; + const isDisabled = isPrimary && this.activeForm !== null && !this.activeFormValid; + return html` +
this.handleMenuOptionClick(actionArg, isPrimary)} + >${actionArg.name}
+ `; + }) ?? ''} +
`; })} @@ -556,6 +577,76 @@ export class DeesStepper extends DeesElement { 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` +

+ Are you sure you want to cancel? Any progress on the current step will be lost. +

+ `, + 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() { const domtools = await this.domtoolsPromise; const container = this.shadowRoot!.querySelector('.stepperContainer');