Files
dees-catalog/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts
Juergen Kunz 39a4bf0dd3 feat(dees-stepper): refactor to use dees-tile and add overlay functionality
- Updated IStep interface to include optional footerContent for step-specific actions.
- Implemented createAndShow static method for displaying stepper as an overlay with DeesWindowLayer.
- Refactored render method to wrap each step in a dees-tile, separating header, body, and footer content.
- Enhanced CSS for dual-mode operation, adjusting styles for overlay and inline modes.
- Added z-index management for overlay stepper to ensure proper stacking with window layer.
- Updated demo to include a button for launching the stepper as an overlay.
2026-04-11 09:28:11 +00:00

402 lines
12 KiB
TypeScript

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 '../dees-tile/dees-tile.js';
export interface IStep {
title: string;
content: TemplateResult;
footerContent?: TemplateResult;
validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise<any>;
onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise<any>;
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[];
}): Promise<DeesStepper> {
const body = document.body;
const stepper = new DeesStepper();
stepper.steps = optionsArg.steps;
stepper.overlay = true;
stepper.windowLayer = await DeesWindowLayer.createAndShow({ blur: true });
stepper.windowLayer.addEventListener('click', async () => {
await stepper.destroy();
});
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;
@property({ type: Number, attribute: false })
accessor stepperZIndex: number = 1000;
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);
}
:host([overlay]) {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
.stepperContainer {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
}
.stepperContainer.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
.stepperContainer.predestroy {
opacity: 0;
transition: opacity 0.2s ease-in;
}
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;
}
.step-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
}
`,
];
public render() {
return html`
<div
class="stepperContainer ${this.overlay ? 'overlay' : ''}"
style=${this.overlay ? `z-index: ${this.stepperZIndex};` : ''}
>
${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`<dees-tile
class="step ${isSelected ? 'selected' : ''} ${isHidden ? 'hiddenStep' : ''} ${isFirst ? 'entrance' : ''}"
>
<div slot="header" class="step-header">
${!isFirst
? html`<div class="goBack" @click=${this.goBack}>
<span style="font-family: Inter"><-</span> go to previous step
</div>`
: html`<div class="goBack-spacer"></div>`}
<div class="stepCounter">
Step ${stepIndex + 1} of ${this.steps.length}
</div>
</div>
<div class="step-body">
<div class="title">${stepArg.title}</div>
<div class="content">${stepArg.content}</div>
</div>
${stepArg.footerContent
? html`<div slot="footer" class="step-footer">${stepArg.footerContent}</div>`
: ''}
</dees-tile>`;
})}
</div>
`;
}
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;
}
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;
}
public async destroy() {
const domtools = await this.domtoolsPromise;
const container = this.shadowRoot!.querySelector('.stepperContainer');
container?.classList.add('predestroy');
await domtools.convenience.smartdelay.delayFor(200);
if (this.parentElement) {
this.parentElement.removeChild(this);
}
if (this.windowLayer) {
await this.windowLayer.destroy();
}
// Unregister from z-index registry
zIndexRegistry.unregister(this);
}
}