/* eslint-disable no-restricted-globals */ import { api, LightningElement } from "lwc"; import cx from "classnames"; // This is a UI-agnostic wrapper component that turns its children components into a sequence of // of steps. The children must be dx-step-sequence-step components. Navigation works both via the browser's // forward/back buttons and via any action buttons defined in the dx-step-sequence-step components (see that // component for details on the action buttons). export default class StepSequence extends LightningElement { @api animateTransitions = false; @api communicateStepChanges = false; @api containerScrollToId = ""; @api forceInitiallyVisible = false; @api initialStepIndex: string | undefined; @api optimizePositionAfterAnimation = false; @api sessionStorageId = ""; @api useHistory = false; private currentStepIndex = 0; private defaultScrollRestorationValue = history.scrollRestoration; private didInitiallyBecomeVisible = false; private isHiddenForInitialAnimation = true; private measuredStepHeights: number[] = []; private slotClassName = "step-sequence-steps-slot"; private steps: Array = []; private get containerClassName() { return cx("step-sequence-container", { "no-animations": !this.animateTransitions, hidden: this.isHiddenForInitialAnimation }); } connectedCallback() { const initialStepIndex = parseInt(this.initialStepIndex as string, 10); if (!isNaN(initialStepIndex)) { this.currentStepIndex = initialStepIndex; } if (this.useHistory) { this.defaultScrollRestorationValue = history.scrollRestoration; history.scrollRestoration = "manual"; // Step component must override scroll behavior to work properly with history and maintain scrollTo behavior of component window.addEventListener("popstate", this.handleHistoryPopstate); window.addEventListener("hashchange", this.handleHashChange); } if (this.sessionStorageId) { sessionStorage.setItem( this.sessionStorageId, JSON.stringify({ currentStepIndex: this.currentStepIndex }) ); } if (this.animateTransitions) { this.addEventListener("transitionend", this.handleTransitionEnd); } else { this.isHiddenForInitialAnimation = false; } } renderedCallback() { // If we're not animating and we want to force visible, we can do it right on first render. // If we're animating, this is handled after the initial animations end (to avoid seeing weird flashes). if (this.forceInitiallyVisible && !this.didInitiallyBecomeVisible) { this.didInitiallyBecomeVisible = true; if (!this.animateTransitions) { this.scrollToStepTop(); } else { setTimeout(() => { this.isHiddenForInitialAnimation = false; requestAnimationFrame(() => { this.scrollToStepTop(); }); }, 400); } } } disconnectedCallback() { window.removeEventListener("popstate", this.handleHistoryPopstate); window.removeEventListener("hashchange", this.handleHashChange); this.removeEventListener("transitionend", this.handleTransitionEnd); history.scrollRestoration = this.defaultScrollRestorationValue; } private scrollToStepTop() { this.template.host.scrollIntoView(); } // Used only if this.animateTransitions is truthy private handleTransitionEnd = ({ target }: Event) => { if ( (target as HTMLElement).tagName?.toLowerCase() !== "dx-step-sequence-step" ) { // Ignore transitions of other nested elements return; } // Ensure subsequent steps are no longer visible: const prevStep = this.steps[this.currentStepIndex - 1]; const nextStep = this.steps[this.currentStepIndex + 1]; if (prevStep?.classList.contains("visible")) { prevStep.classList.remove("visible"); } if (nextStep?.classList.contains("visible")) { nextStep.classList.remove("visible"); } // Mark the current step as fully active: this.steps[this.currentStepIndex].classList.add("active-step"); // Remove temporary minHeight set for nice animations, so that component will resize properly: this.template.querySelector( ".step-sequence-container" )!.style.minHeight = "auto"; }; // NOTE: This is the ultimate coordinator of step transitions; it should all go through here. private changeActiveStep(nextStepIndex: number, updateHistory = true) { if ( nextStepIndex === this.currentStepIndex || !this.isStepIndexWithinBounds(nextStepIndex) ) { // Should never happen, but covering all logical bases. Nothing to do here. return; } if (this.optimizePositionAfterAnimation) { this.scrollToStepTop(); } if (!this.animateTransitions) { this.steps[this.currentStepIndex].className = ""; this.steps[nextStepIndex].className = "visible active-step"; } else { // Set minHeight temporarily to ensure nice animations: this.template.querySelector( ".step-sequence-container" )!.style.minHeight = `${ this.measuredStepHeights[nextStepIndex] || this.steps[nextStepIndex].clientHeight }px`; // If animations are enabled, we need to animate in the correct directions: if (nextStepIndex > this.currentStepIndex) { this.steps[this.currentStepIndex].className = "visible animate-left-out"; this.steps[nextStepIndex].className = "visible animate-in"; } else { this.steps[this.currentStepIndex].className = "visible animate-right-out"; this.steps[nextStepIndex].className = "visible animate-in"; } // Ensure focus cannot drift to invisible off-screen components this.steps[nextStepIndex].removeAttribute("inert"); this.steps[this.currentStepIndex].setAttribute("inert", "true"); this.steps[nextStepIndex].focus(); } if (this.useHistory && updateHistory) { if (typeof history.state?.currentStepIndex !== "number") { // This is a "new" interaction with the step component, so we store the current // component state in history so that going back from the next step works right history.pushState( { currentStepIndex: this.currentStepIndex }, "", this.containerScrollToId // update browser hash for correct "feel" to in-page navigation ); } history.pushState( { currentStepIndex: nextStepIndex }, "" ); } if (this.sessionStorageId) { sessionStorage.setItem( this.sessionStorageId, JSON.stringify({ currentStepIndex: nextStepIndex }) ); } this.currentStepIndex = nextStepIndex; if (this.communicateStepChanges) { this.dispatchEvent( new CustomEvent("stepsequencechange", { detail: { stepIndex: this.currentStepIndex }, composed: true, bubbles: true }) ); } } private initializeStepAnimationClasses( currentStepIndex: number, step: HTMLElement, stepIndex: number ) { // All steps to the "left" of the current step are treated as though they've already // animated out in that direction, and similarly for all steps to the "right." Only // the current step is initialized as visible and active. if (stepIndex < currentStepIndex) { step.className = "animate-left-out"; } else if (stepIndex > currentStepIndex) { step.className = "animate-right-out"; } else { step.className = "visible active-step"; } } @api public getCurrentStepIndex(): number { return this.currentStepIndex; } // This method is available in case there are scenarios where a sequence needs to "jump" in some cases @api public jumpToStep(stepIndex: number) { if (!this.isStepIndexWithinBounds(stepIndex)) { // illegal value; ignore return; } this.changeActiveStep(stepIndex); if (this.animateTransitions) { this.steps.forEach((step, index) => this.initializeStepAnimationClasses(stepIndex, step, index) ); } } @api public remeasureSteps() { this.measuredStepHeights = this.steps.map((step) => step.clientHeight); } private isStepIndexWithinBounds(stepIndex: number) { return stepIndex >= 0 && stepIndex < this.steps.length; } handleHistoryPopstate = ({ state }: PopStateEvent) => { if ( typeof state?.currentStepIndex !== "number" || state.currentStepIndex === this.currentStepIndex ) { // This history item is not a step change, so bail early. return; } this.remeasureSteps(); this.changeActiveStep(state.currentStepIndex, false); this.scrollToStepTop(); }; handleHashChange = ({ newURL }: HashChangeEvent) => { const newHash = new URL(newURL).hash; const oldScrollValue = history.state?.scroll?.value; if (typeof oldScrollValue === "number") { window.scrollTo({ top: oldScrollValue }); } else if (newHash) { document.querySelector(newHash)?.scrollIntoView(); } else { // No hash and no old scroll value means this was the initial history item window.scrollTo({ top: 0 }); } }; handleStepDecrement(e: CustomEvent) { e.stopPropagation(); if (this.currentStepIndex <= 0) { return; } this.changeActiveStep(this.currentStepIndex - 1); } handleStepIncrement(e: CustomEvent) { e.stopPropagation(); if (this.currentStepIndex >= this.steps.length - 1) { return; } this.changeActiveStep(this.currentStepIndex + 1); } // Assign steps and appropriate classNames when slots are loaded. This component was built on // the assumption that the slotChange will only happen at initial load, though it SHOULD still // work even if the slots are changed later (though note that the value of // `this.currentStepIndex` will be applied to the new slotted elements; call `jumpToStep` to // change it). handleSlotChange(e: Event) { const slot = e.target as HTMLSlotElement; if (slot.className !== this.slotClassName) { return; } this.steps = slot.assignedElements() as HTMLElement[]; this.steps.forEach((step, index) => { if (this.animateTransitions) { this.initializeStepAnimationClasses( this.currentStepIndex, step, index ); if (index !== this.currentStepIndex) { // Ensure focus cannot drift to invisible off-screen components step.setAttribute("inert", "true"); } } }); this.steps[this.currentStepIndex].className = "visible active-step"; } }