580 lines
18 KiB
TypeScript
580 lines
18 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 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<DeesStepper>[];
|
|
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;
|
|
|
|
@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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* --- 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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
`,
|
|
];
|
|
|
|
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.menuOptions && stepArg.menuOptions.length > 0
|
|
? html`<div slot="footer" class="bottomButtons">
|
|
${isSelected && this.activeForm !== null && !this.activeFormValid
|
|
? html`<div class="stepHint">Complete form to continue</div>`
|
|
: ''}
|
|
${stepArg.menuOptions.map((actionArg, actionIndex) => {
|
|
const isPrimary = actionIndex === stepArg.menuOptions!.length - 1;
|
|
const isDisabled = isPrimary && this.activeForm !== null && !this.activeFormValid;
|
|
return html`
|
|
<div
|
|
class="bottomButton ${isPrimary ? 'primary' : ''} ${isDisabled ? 'disabled' : ''}"
|
|
@click=${() => this.handleMenuOptionClick(actionArg, isPrimary)}
|
|
>${actionArg.name}</div>
|
|
`;
|
|
})}
|
|
</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;
|
|
}
|
|
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 <dees-form> 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<DeesStepper>,
|
|
isPrimary: boolean,
|
|
) {
|
|
const form = this.activeForm;
|
|
if (isPrimary && form) {
|
|
if (!this.activeFormValid) return;
|
|
await new Promise<void>((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');
|
|
container?.classList.add('predestroy');
|
|
await domtools.convenience.smartdelay.delayFor(200);
|
|
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);
|
|
}
|
|
}
|