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``
: ''}
`;
})}
@@ -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);
}