diff --git a/changelog.md b/changelog.md index 65a9282..58bc459 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-11 - 3.71.0 - feat(dees-stepper) +add footer menu actions with form-aware step validation + +- replace step footer submit handling with configurable menuOptions actions +- disable the primary footer action until required form fields are completed and show a completion hint +- dispatch form data before running primary step actions and clean up form subscriptions on destroy +- adjust overlay host positioning so the stepper container controls viewport layering correctly + ## 2026-04-11 - 3.70.1 - fix(dees-modal) use icon font sizing for modal header buttons diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 4c97c90..6b03c74 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.70.1', + version: '3.71.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.demo.ts b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts index 8e85fdf..472dd3f 100644 --- a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts +++ b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts @@ -8,13 +8,11 @@ const demoSteps: IStep[] = [ - Continue `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, + menuOptions: [ + { name: 'Continue', action: async (stepper) => stepper!.goNext() }, + ], }, { title: 'Profile Details', @@ -22,13 +20,11 @@ const demoSteps: IStep[] = [ - Continue `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, + menuOptions: [ + { name: 'Continue', action: async (stepper) => stepper!.goNext() }, + ], }, { title: 'Contact Information', @@ -36,13 +32,11 @@ const demoSteps: IStep[] = [ - Continue `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, + menuOptions: [ + { name: 'Continue', action: async (stepper) => stepper!.goNext() }, + ], }, { title: 'Team Size', @@ -59,13 +53,11 @@ const demoSteps: IStep[] = [ ]} required > - Continue `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, + menuOptions: [ + { name: 'Continue', action: async (stepper) => stepper!.goNext() }, + ], }, { title: 'Goals', @@ -81,13 +73,11 @@ const demoSteps: IStep[] = [ ]} required > - Continue `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, + menuOptions: [ + { name: 'Continue', action: async (stepper) => stepper!.goNext() }, + ], }, { title: 'Brand Preferences', @@ -95,13 +85,11 @@ const demoSteps: IStep[] = [ - Continue `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, + menuOptions: [ + { name: 'Continue', action: async (stepper) => stepper!.goNext() }, + ], }, { title: 'Integrations', @@ -112,13 +100,11 @@ const demoSteps: IStep[] = [ label="Integrations in use" placeholder="Add integration" > - Continue `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, + menuOptions: [ + { name: 'Continue', action: async (stepper) => stepper!.goNext() }, + ], }, { title: 'Review & Launch', @@ -127,6 +113,9 @@ const demoSteps: IStep[] = [

Almost there! Review your selections and launch whenever you're ready.

`, + menuOptions: [ + { name: 'Launch', action: async (stepper) => stepper!.goNext() }, + ], }, ]; 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 50e2627..e7dbc80 100644 --- a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts +++ b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts @@ -18,12 +18,13 @@ 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 type { DeesForm } from '../../00group-form/dees-form/dees-form.js'; import '../dees-tile/dees-tile.js'; export interface IStep { title: string; content: TemplateResult; - footerContent?: TemplateResult; + menuOptions?: plugins.tsclass.website.IMenuItem[]; validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise; onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise; validationFuncCalled?: boolean; @@ -83,6 +84,14 @@ export class DeesStepper extends DeesElement { @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() { @@ -102,12 +111,18 @@ export class DeesStepper extends DeesElement { 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: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; + position: static; + width: 0; + height: 0; } .stepperContainer { @@ -242,12 +257,87 @@ export class DeesStepper extends DeesElement { padding: 32px; } - .step-footer { + /* --- 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; + border-left: 1px solid var(--dees-color-border-subtle); + color: var(--dees-color-text-muted); + white-space: nowrap; display: flex; align-items: center; - justify-content: flex-end; - gap: 8px; - padding: 12px 16px; + } + + .bottomButtons .bottomButton:first-child { + border-left: none; + } + + .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; } `, ]; @@ -280,8 +370,22 @@ export class DeesStepper extends DeesElement {
${stepArg.title}
${stepArg.content}
- ${stepArg.footerContent - ? html`` + ${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}
+ `; + })} +
` : ''} `; })} @@ -316,6 +420,7 @@ export class DeesStepper extends DeesElement { if (!selectedStepElement) { return; } + this.scanActiveForm(selectedStepElement); if (!stepperContainer.style.paddingTop) { stepperContainer.style.paddingTop = `${ stepperContainer.offsetHeight / 2 - selectedStepElement.offsetHeight / 2 @@ -383,6 +488,74 @@ export class DeesStepper extends DeesElement { 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); + } + public async destroy() { const domtools = await this.domtoolsPromise; const container = this.shadowRoot!.querySelector('.stepperContainer'); @@ -395,6 +568,11 @@ export class DeesStepper extends DeesElement { 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); }