UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

445 lines (379 loc) 12.7 kB
import { instanceSymbol } from "../../constants.mjs"; import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { assembleMethodSymbol, CustomElement, getSlottedElements, registerCustomElement, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent, fireCustomEvent, } from "../../dom/events.mjs"; import { isFunction } from "../../types/is.mjs"; import { WizardNavigationStyleSheet } from "./stylesheet/wizard-navigation.mjs"; const wizardNavigationElementSymbol = Symbol("wizardNavigationElement"); const wizardNavigationListElementSymbol = Symbol("wizardNavigationListElement"); const currentStepIndexSymbol = Symbol("currentStepIndex"); const stepsSymbol = Symbol("steps"); const isTransitioningSymbol = Symbol("isTransitioning"); const currentSubStepIndexSymbol = Symbol("currentSubStepIndex"); /** * A WizardNavigation component to display progress and allow navigation through a series of steps, * including support for nested sub-steps. * * @fragments /fragments/components/navigation/wizard-navigation * @example /examples/components/navigation/wizard-navigation-simple * @since 4.26.0 * @copyright Volker Schukai * @summary A vertical step-by-step navigation component for wizards. * @fires monster-wizard-step-changed - Fired when the main active step changes. * @fires monster-wizard-substep-changed - Fired when the active sub-step changes. * @fires monster-wizard-completed - Fired when the entire wizard is marked as complete via completeAll(). */ class WizardNavigation extends CustomElement { static get [instanceSymbol]() { return Symbol.for( "@schukai/monster/components/navigation/wizard-navigation@@instance", ); } [assembleMethodSymbol]() { super[assembleMethodSymbol](); this[isTransitioningSymbol] = false; this[currentStepIndexSymbol] = -1; this[currentSubStepIndexSymbol] = -1; initControlReferences.call(this); initEventHandler.call(this); queueMicrotask(() => { importContent.call(this); this.activate(0, 0, { force: true }); }); return this; } /** * To set the options via the HTML Tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} actions Action callbacks * @property {function} actions.beforeStepChange - Callback fired before a main step change. Must return `true` or a Promise that resolves to `true` to allow the transition. Parameters are `fromIndex` and `toIndex`. * @property {function} actions.click - General click handler callback. */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, actions: { beforeStepChange: (fromIndex, toIndex) => true, click: () => {}, }, }); } /** * Navigates to the specified main step if the transition logic allows it. * @param {number} index The index of the target step. * @param {object} [options={}] Additional options. * @param {boolean} [options.force=false] If true, the `beforeStepChange` callback is skipped. * @returns {Promise<void>} * @fires monster-wizard-step-changed */ async goToStep(index, options = {}) { if (typeof index !== "number") { console.error("WizardNavigation.goToStep: index must be a number."); return; } const { force = false } = options; const stepsData = this[stepsSymbol]; const oldIndex = this[currentStepIndexSymbol]; if ( !stepsData || index < 0 || index >= stepsData.length || index === oldIndex || this[isTransitioningSymbol] ) { return; } this[isTransitioningSymbol] = true; if (!force) { const callback = this.getOption("actions.beforeStepChange"); if (isFunction(callback)) { const isAllowed = await Promise.resolve( callback.call(this, oldIndex, index), ); if (!isAllowed) { this[isTransitioningSymbol] = false; return; } } } stepsData.forEach((stepData, i) => { stepData.element.classList.remove("step-active"); stepData.element.setAttribute("aria-selected", "false"); if (stepData.subStepList) { stepData.subStepList.style.maxHeight = i === index ? stepData.subStepList.scrollHeight + "px" : "0px"; } if (i < index) { stepData.element.classList.add("step-completed"); stepData.subSteps.forEach((sub) => sub.classList.add("sub-item-completed"), ); } else { stepData.element.classList.remove("step-completed"); stepData.subSteps.forEach((sub) => sub.classList.remove("sub-item-completed"), ); } }); if (oldIndex !== -1 && stepsData[oldIndex]) { stepsData[oldIndex].subSteps.forEach((sub) => sub.classList.remove("sub-item-active"), ); } this[currentSubStepIndexSymbol] = -1; this[currentStepIndexSymbol] = index; const currentStepData = stepsData[index]; currentStepData.element.classList.remove("step-completed"); currentStepData.element.classList.add("step-active"); currentStepData.element.setAttribute("aria-selected", "true"); fireCustomEvent(this, "monster-wizard-step-changed", { newIndex: index, oldIndex: oldIndex, element: currentStepData.element, }); this[isTransitioningSymbol] = false; } /** * Activates a specific main step and sub-step. * @param {number} mainIndex The index of the main step. * @param {number} subIndex The index of the sub-step. * @param {object} [options={}] Additional options. * @param {boolean} [options.force=false] If true, the `beforeStepChange` callback is skipped. * @returns {Promise<void>} * @fires monster-wizard-substep-changed */ async activate(mainIndex, subIndex, options = {}) { if (typeof mainIndex !== "number" || typeof subIndex !== "number") { console.error( "WizardNavigation.activate: mainIndex and subIndex must be numbers.", ); return; } await this.goToStep(mainIndex, options); const stepsData = this[stepsSymbol]; if (!stepsData || !stepsData[mainIndex]) return; const targetStep = stepsData[mainIndex]; if ( !targetStep.subSteps || subIndex < 0 || subIndex >= targetStep.subSteps.length ) { return; } stepsData.forEach((step) => { step.subSteps.forEach((subStep) => subStep.classList.remove("sub-item-active"), ); }); for (let i = 0; i < subIndex; i++) { if (targetStep.subSteps[i]) { targetStep.subSteps[i].classList.add("sub-item-completed"); } } const targetSubStep = targetStep.subSteps[subIndex]; targetSubStep.classList.remove("sub-item-completed"); targetSubStep.classList.add("sub-item-active"); this[currentSubStepIndexSymbol] = subIndex; fireCustomEvent(this, "monster-wizard-substep-changed", { mainIndex, subIndex, element: targetSubStep, }); } /** * Navigates to the next step or sub-step in sequence. * @returns {Promise<void>} */ async next() { const mainIndex = this[currentStepIndexSymbol]; const subIndex = this[currentSubStepIndexSymbol]; const stepsData = this[stepsSymbol]; if (mainIndex === -1 || !stepsData || !stepsData[mainIndex]) return; const currentStepData = stepsData[mainIndex]; const hasSubSteps = currentStepData.subSteps.length > 0; if (hasSubSteps && subIndex < currentStepData.subSteps.length - 1) { await this.activate(mainIndex, subIndex + 1); return; } const nextMainIndex = mainIndex + 1; if (nextMainIndex < stepsData.length) { if (stepsData[nextMainIndex].subSteps.length > 0) { await this.activate(nextMainIndex, 0); } else { await this.goToStep(nextMainIndex); } } } /** * Navigates to the previous step or sub-step in sequence. * @returns {Promise<void>} */ async previous() { const mainIndex = this[currentStepIndexSymbol]; const subIndex = this[currentSubStepIndexSymbol]; const stepsData = this[stepsSymbol]; if (subIndex > 0) { await this.activate(mainIndex, subIndex - 1); return; } const prevMainIndex = mainIndex - 1; if (prevMainIndex >= 0) { const prevStepData = stepsData[prevMainIndex]; if (prevStepData.subSteps.length > 0) { await this.activate(prevMainIndex, prevStepData.subSteps.length - 1); } else { await this.goToStep(prevMainIndex); } } } /** * Marks the current step as completed and automatically navigates to the next step. * @returns {Promise<void>} */ async completeAndNext() { const currentIndex = this[currentStepIndexSymbol]; const stepsData = this[stepsSymbol]; if (currentIndex === -1 || !stepsData || !stepsData[currentIndex]) { return; } const currentStepData = stepsData[currentIndex]; currentStepData.element.classList.add("step-completed"); currentStepData.element.classList.remove("step-active"); currentStepData.subSteps.forEach((sub) => { sub.classList.add("sub-item-completed"); sub.classList.remove("sub-item-active"); }); await this.next(); } /** * Marks the entire wizard workflow as completed. * @fires monster-wizard-completed */ completeAll() { const stepsData = this[stepsSymbol]; if (!stepsData) return; stepsData.forEach((stepData) => { stepData.element.classList.add("step-completed"); stepData.element.classList.remove("step-active"); stepData.element.setAttribute("aria-selected", "false"); stepData.subSteps.forEach((sub) => { sub.classList.add("sub-item-completed"); sub.classList.remove("sub-item-active"); }); if (stepData.subStepList) { stepData.subStepList.style.maxHeight = "0px"; } }); this[currentStepIndexSymbol] = -1; this[currentSubStepIndexSymbol] = -1; fireCustomEvent(this, "monster-wizard-completed", { detail: { message: "All steps completed." }, }); } /** * Gets the index of the currently active main step. * @returns {number} The index of the current step, or -1 if none is active. */ getCurrentStepIndex() { return this[currentStepIndexSymbol]; } /** * Gets the index of the currently active sub-step within the main step. * @returns {number} The index of the current sub-step, or -1 if none is active. */ getCurrentSubStepIndex() { return this[currentSubStepIndexSymbol]; } static getTag() { return "monster-wizard-navigation"; } static getCSSStyleSheet() { return [WizardNavigationStyleSheet]; } } /** @private */ function initEventHandler() { const self = this; this[wizardNavigationElementSymbol].addEventListener( "click", function (event) { const targetStepElement = findTargetElementFromEvent(event, "li.step"); if (!targetStepElement) return; const targetIndex = self[stepsSymbol].findIndex( (stepData) => stepData.element === targetStepElement, ); if (targetIndex !== -1) { self.goToStep(targetIndex); } }, ); } /** @private */ function initControlReferences() { this[wizardNavigationElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="control"]`, ); this[wizardNavigationListElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="list"]`, ); } /** @private */ function importContent() { const elements = getSlottedElements.call(this, "ol.wizard-steps"); elements.forEach((element) => { const clonedContent = element.cloneNode(true); this[wizardNavigationListElementSymbol].innerHTML = ""; this[wizardNavigationListElementSymbol].appendChild(clonedContent); const stepElements = this[wizardNavigationListElementSymbol].querySelectorAll("li.step"); this[stepsSymbol] = Array.from(stepElements).map((stepEl) => { const subStepList = stepEl.querySelector("ul"); const subSteps = subStepList ? Array.from(subStepList.querySelectorAll("li")) : []; if (subStepList) { subStepList.style.maxHeight = "0px"; subStepList.style.overflow = "hidden"; subStepList.style.transition = "max-height 0.4s ease-in-out"; } return { element: stepEl, subStepList: subStepList, subSteps: subSteps, }; }); this[currentStepIndexSymbol] = -1; this[currentSubStepIndexSymbol] = -1; this[wizardNavigationListElementSymbol] .querySelector("ol") ?.setAttribute("role", "tablist"); this[stepsSymbol].forEach((stepData) => { stepData.element.setAttribute("role", "tab"); stepData.element.setAttribute("aria-selected", "false"); }); }); } /** @private */ function getTemplate() { return ` <div data-monster-role="control" part="control"> <slot style="display: none;"></slot> <div data-monster-role="list"></div> </div>`; } registerCustomElement(WizardNavigation);