@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
445 lines (379 loc) • 12.7 kB
JavaScript
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);