import * as plugins from '../../00plugins.js'; import { DeesElement, customElement, html, css, unsafeCSS, type CSSResult, cssManager, property, type TemplateResult, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { stepperDemo } from './dees-stepper.demo.js'; 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'; export interface IStep { title: string; content: TemplateResult; menuOptions?: plugins.tsclass.website.IMenuItem[]; validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise; onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise; validationFuncCalled?: boolean; abortController?: AbortController; } declare global { interface HTMLElementTagNameMap { 'dees-stepper': DeesStepper; } } @customElement('dees-stepper') export class DeesStepper extends DeesElement { // STATIC public static demo = stepperDemo; public static demoGroups = ['Layout', 'Form']; 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 }); body.append(stepper.windowLayer); body.append(stepper); // Get z-index for stepper (should be above window layer) stepper.stepperZIndex = zIndexRegistry.getNextZIndex(); zIndexRegistry.register(stepper, stepper.stepperZIndex); return stepper; } // INSTANCE @property({ type: Array, }) accessor steps: IStep[] = []; @property({ type: Object, }) accessor selectedStep!: IStep; @property({ type: Boolean, reflect: true, }) 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; @property({ type: Object, attribute: false }) accessor activeForm: DeesForm | null = null; @property({ type: Boolean, attribute: false }) accessor activeFormValid: boolean = true; private activeFormSubscription?: { unsubscribe: () => void }; private windowLayer?: DeesWindowLayer; constructor() { super(); } public static styles = [ themeDefaultStyles, cssManager.defaultStyles, css` /* TODO: Migrate hardcoded values to --dees-* CSS variables */ :host { position: absolute; width: 100%; height: 100%; font-family: ${cssGeistFontFamily}; color: var(--dees-color-text-primary); } /* * In overlay mode the host is "transparent" to layout — the inner * .stepperContainer.overlay is what pins to the viewport and carries the * z-index. Keeping :host unpositioned avoids nesting the stacking context * under an auto-z-index parent (which was trapping .stepperContainer * below DeesWindowLayer's sibling layers). This mirrors how dees-modal * keeps its own :host unpositioned and lets .modalContainer drive layout. */ :host([overlay]) { position: static; width: 0; height: 0; } .stepperContainer { position: absolute; width: 100%; height: 100%; overflow: hidden; } .stepperContainer.overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; } /* Exit animation — reverse of the entry. Tiles slide DOWN + fade out, mirroring the .entrance slide-up. The transition override is needed because dees-tile.step has its own 0.7s transition for step selection which would otherwise make the exit sluggish. */ .stepperContainer.predestroy dees-tile.step { transform: translateY(16px); filter: opacity(0); transition: transform 0.25s, filter 0.2s; } dees-tile.step { position: relative; pointer-events: none; max-width: 500px; min-height: 300px; margin: auto; margin-bottom: 20px; filter: opacity(0.55) saturate(0.85); transition: transform 0.7s cubic-bezier(0.87, 0, 0.13, 1), filter 0.7s cubic-bezier(0.87, 0, 0.13, 1); user-select: none; } dees-tile.step.selected { pointer-events: all; filter: opacity(1) saturate(1); user-select: auto; } dees-tile.step.hiddenStep { filter: opacity(0); } dees-tile.step.entrance { transition: transform 0.35s ease, filter 0.35s ease; } dees-tile.step.entrance.hiddenStep { transform: translateY(16px); } dees-tile.step:last-child { margin-bottom: 100vh; } .stepperContainer.overlay dees-tile.step::part(outer) { box-shadow: 0 0 0 1px ${cssManager.bdTheme('hsl(0 0% 0% / 0.03)', 'hsl(0 0% 100% / 0.03)')}, 0 8px 40px ${cssManager.bdTheme('hsl(0 0% 0% / 0.12)', 'hsl(0 0% 0% / 0.5)')}, 0 2px 8px ${cssManager.bdTheme('hsl(0 0% 0% / 0.06)', 'hsl(0 0% 0% / 0.25)')}; } .step-header { height: 36px; display: flex; align-items: center; justify-content: space-between; padding: 0 8px 0 12px; gap: 12px; } .goBack-spacer { width: 1px; } .step-header .goBack { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 8px; font-size: 12px; font-weight: 500; line-height: 1; border: none; background: transparent; color: var(--dees-color-text-muted); border-radius: 4px; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, transform 0.2s ease; } .step-header .goBack:hover { background: var(--dees-color-hover); color: var(--dees-color-text-secondary); transform: translateX(-2px); } .step-header .goBack:active { background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(0 0% 15%)')}; } .step-header .goBack span { transition: transform 0.2s ease; display: inline-block; } .step-header .goBack:hover span { transform: translateX(-2px); } .step-header .stepCounter { color: var(--dees-color-text-muted); font-size: 12px; font-weight: 500; letter-spacing: -0.01em; padding: 0 8px; } .step-body .title { text-align: center; padding-top: 32px; font-family: 'Geist Sans', sans-serif; font-size: 24px; font-weight: 600; letter-spacing: -0.01em; color: inherit; } .step-body .content { padding: 32px; } /* --- Footer: modal-style bottom buttons --- */ .bottomButtons { display: flex; flex-direction: row; justify-content: flex-end; align-items: center; gap: 0; height: 36px; width: 100%; box-sizing: border-box; } .bottomButtons .bottomButton { padding: 0 16px; height: 100%; text-align: center; font-size: 12px; font-weight: 500; cursor: pointer; user-select: none; transition: all 0.15s ease; background: transparent; border: none; color: var(--dees-color-text-muted); white-space: nowrap; display: flex; align-items: center; } /* 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 { background: var(--dees-color-hover); color: var(--dees-color-text-primary); } .bottomButtons .bottomButton:active { background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(0 0% 13%)')}; } .bottomButtons .bottomButton.primary { color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')}; font-weight: 600; } .bottomButtons .bottomButton.primary:hover { background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')}; color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')}; } .bottomButtons .bottomButton.primary:active { background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.12)', 'hsl(213.1 93.9% 67.8% / 0.12)')}; } .bottomButtons .bottomButton.disabled { pointer-events: none; opacity: 0.4; cursor: not-allowed; } .bottomButtons .bottomButton.disabled:hover { background: transparent; color: var(--dees-color-text-muted); } /* Hint shown on the left of the footer when the active step's form has unfilled required fields. Uses margin-right: auto to push right-aligned buttons to the right while keeping the hint flush-left. */ .bottomButtons .stepHint { margin-right: auto; padding: 0 16px; font-size: 11px; line-height: 1; letter-spacing: -0.01em; color: var(--dees-color-text-muted); display: flex; align-items: center; user-select: none; } `, ]; public render() { return html`
${this.steps.map((stepArg, stepIndex) => { const isSelected = stepArg === this.selectedStep; const isHidden = this.getIndexOfStep(stepArg) > this.getIndexOfStep(this.selectedStep); const isFirst = stepIndex === 0; return html`
${!isFirst ? html`
<- go to previous step
` : html`
`}
Step ${stepIndex + 1} of ${this.steps.length}
${stepArg.title}
${stepArg.content}
${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}
`; }) ?? ''}
`; })}
`; } public getIndexOfStep = (stepArg: IStep): number => { return this.steps.findIndex((stepArg2) => stepArg === stepArg2); }; public async firstUpdated() { await this.domtoolsPromise; await this.domtools.convenience.smartdelay.delayFor(0); this.selectedStep = this.steps[0]; 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 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; } this.scanActiveForm(selectedStepElement); if (!stepperContainer.style.paddingTop) { stepperContainer.style.paddingTop = `${ 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(); if (!this.scroller) { this.scroller = new domtools.plugins.SweetScroll( { vertical: true, horizontal: false, easing: 'easeInOutExpo', duration: 700, }, stepperContainer ); } 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); } this.scroller.to(scrollPosition); } public async goBack() { const currentIndex = this.steps.findIndex((stepArg) => stepArg === this.selectedStep); if (currentIndex <= 0) { return; } // Abort any active listeners on current step if (this.selectedStep.abortController) { this.selectedStep.abortController.abort(); } const currentStep = this.steps[currentIndex]; currentStep.validationFuncCalled = false; const previousStep = this.steps[currentIndex - 1]; previousStep.validationFuncCalled = false; this.selectedStep = previousStep; await this.domtoolsPromise; await this.domtools.convenience.smartdelay.delayFor(100); this.selectedStep.onReturnToStepFunc?.(this, this.shadowRoot!.querySelector('.selected') as HTMLElement); } public goNext() { const currentIndex = this.steps.findIndex((stepArg) => stepArg === this.selectedStep); if (currentIndex < 0 || currentIndex >= this.steps.length - 1) { return; } // Abort any active listeners on current step if (this.selectedStep.abortController) { this.selectedStep.abortController.abort(); } const currentStep = this.steps[currentIndex]; currentStep.validationFuncCalled = false; const nextStep = this.steps[currentIndex + 1]; nextStep.validationFuncCalled = false; this.selectedStep = nextStep; } /** * Scans the currently selected step for a in its content. When * found, subscribes to the form's RxJS changeSubject so the primary * menuOption button can auto-enable/disable as required fields are filled. * * If the form reference is the same as the previous activation (e.g. on a * same-step re-render), we just recompute validity without re-subscribing. */ private scanActiveForm(selectedStepElement: HTMLElement) { const form = selectedStepElement.querySelector('dees-form') as DeesForm | null; if (form === this.activeForm) { this.recomputeFormValid(); return; } this.activeFormSubscription?.unsubscribe(); this.activeFormSubscription = undefined; this.activeForm = form; if (!form) { this.activeFormValid = true; return; } // Initial check before subscribing, in case the form's firstUpdated fires // synchronously between scan and subscribe. this.recomputeFormValid(); this.activeFormSubscription = form.changeSubject.subscribe(() => { this.recomputeFormValid(); }); } /** * Recomputes activeFormValid by checking every required field in the active * form for a non-empty value. Mirrors dees-form.updateRequiredStatus's logic * but stores the result on the stepper instead of mutating a submit button. */ private recomputeFormValid() { const form = this.activeForm; if (!form) { this.activeFormValid = true; return; } const fields = form.getFormElements(); this.activeFormValid = fields.every( (field) => !field.required || (field.value !== null && field.value !== undefined && field.value !== ''), ); } /** * Click handler for menuOption buttons in the footer. For the primary (last) * button, if an active form is present, gates on required-field validity and * triggers the form's gatherAndDispatch() before running the action. The * action is awaited so any async work (e.g. goNext → scroll animation) * completes before the click handler returns. */ private async handleMenuOptionClick( optionArg: plugins.tsclass.website.IMenuItem, isPrimary: boolean, ) { const form = this.activeForm; if (isPrimary && form) { if (!this.activeFormValid) return; await new Promise((resolve) => { form.addEventListener('formData', () => resolve(), { once: true }); form.gatherAndDispatch(); }); } 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; modal!.destroy(); // fire-and-forget — starts the confirm modal fade const domtools = await this.domtoolsPromise; await domtools.convenience.smartdelay.delayFor(100); await this.destroy(); }, }, ], }); } public async destroy() { const domtools = await this.domtoolsPromise; const container = this.shadowRoot!.querySelector('.stepperContainer'); container?.classList.add('predestroy'); await domtools.convenience.smartdelay.delayFor(250); if (this.parentElement) { this.parentElement.removeChild(this); } if (this.windowLayer) { await this.windowLayer.destroy(); } // Tear down form subscription to avoid leaks when the overlay closes. this.activeFormSubscription?.unsubscribe(); this.activeFormSubscription = undefined; this.activeForm = null; // Unregister from z-index registry zIndexRegistry.unregister(this); } }