@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
1,327 lines (1,170 loc) • 36.4 kB
JavaScript
/**
* Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
* Node module: @schukai/monster
*
* This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
* The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
*
* For those who do not wish to adhere to the AGPLv3, a commercial license is available.
* Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
* For more information about purchasing a commercial license, please contact Volker Schukai.
*
* SPDX-License-Identifier: AGPL-3.0
*/
import { instanceSymbol } from "../../constants.mjs";
import {
assembleMethodSymbol,
CustomElement,
getSlottedElements,
registerCustomElement,
} from "../../dom/customelement.mjs";
import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
import { getLocaleOfDocument } from "../../dom/locale.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { isFunction, isObject } from "../../types/is.mjs";
import { Observer } from "../../types/observer.mjs";
import { WizardStyleSheet } from "./stylesheet/wizard.mjs";
import "../navigation/wizard-navigation.mjs";
import "./button-bar.mjs";
import "./message-state-button.mjs";
export { Wizard };
const controlElementSymbol = Symbol("controlElement");
const navigationElementSymbol = Symbol("navigationElement");
const slotElementSymbol = Symbol("slotElement");
const previousButtonElementSymbol = Symbol("previousButtonElement");
const nextButtonElementSymbol = Symbol("nextButtonElement");
const submitButtonElementSymbol = Symbol("submitButtonElement");
const statusElementSymbol = Symbol("statusElement");
const panelRecordsSymbol = Symbol("panelRecords");
const groupsSymbol = Symbol("groups");
const currentPanelIndexSymbol = Symbol("currentPanelIndex");
const optionObserverSymbol = Symbol("optionObserver");
const idsSymbol = Symbol("ids");
const finalRecordSymbol = Symbol("finalRecord");
/**
* A reusable wizard container that combines content panels with `monster-wizard-navigation`.
*
* Every direct child panel that defines `data-monster-option-navigation-label` becomes a wizard panel.
* Panels sharing the same label are grouped into one main step. Optional sub labels can be defined with
* `data-monster-option-navigation-sub-label`.
*
* @fragments /fragments/components/form/wizard/
* @example /examples/components/form/wizard-basic
* @since 4.126.0
* @summary A generic wizard control for checkout, creation and multi-step forms.
* @fires monster-wizard-refresh
* @fires monster-wizard-change-request
* @fires monster-wizard-validation
* @fires monster-wizard-panel-change
* @fires monster-wizard-invalid
* @fires monster-wizard-complete
* @fires monster-wizard-final-panel-show
*/
class Wizard extends CustomElement {
static get [instanceSymbol]() {
return Symbol.for("@schukai/monster/components/form/wizard@@instance");
}
/**
* 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}
*
* @property {Object} templates Template definitions
* @property {string} templates.main Main template
* @property {Object} labels Text labels
* @property {string} labels.previous Previous button label
* @property {string} labels.next Next button label
* @property {string} labels.submit Submit button label
* @property {string} labels.stepStatus Step status pattern with `{current}` and `{total}`
* @property {Object} features Feature toggles
* @property {boolean} features.allowBackNavigation Allows navigation to previous panels
* @property {boolean} features.allowForwardNavigation Allows navigation to next panels
* @property {boolean} features.allowDirectNavigation Allows skipping ahead using the navigation
* @property {boolean} features.showActions Shows the action buttons row
* @property {boolean} features.hideSubmittedActions Hides buttons after completion
* @property {boolean} features.autoFocus Focuses the first focusable element of the active panel
* @property {boolean} features.keepCompletedNavigationState Marks visited items as completed in the navigation
* @property {Object} validation Validation settings
* @property {boolean} validation.native Runs `reportValidity()` on native forms and form controls before moving forward
* @property {string} validation.selector Selector for standalone controls that should run `reportValidity()`
* @property {function|null} validation.callback Global validation callback receiving a transition context
* @property {Object<string,function>} validation.panels Panel validation callbacks, keyed by panel id
* @property {Object<string,function>} validation.groups Group validation callbacks, keyed by group id
* @property {Object} actions Callback actions
* @property {function|null} actions.beforeStepChange Called before a panel change. Return `false` to cancel.
* @property {function|null} actions.afterStepChange Called after a panel change.
* @property {function|null} actions.invalid Called when validation blocks navigation.
* @property {function|null} actions.complete Called when submit is triggered on the last panel.
*/
get defaults() {
return Object.assign({}, super.defaults, {
templates: {
main: getTemplate(),
},
labels: getTranslations(),
features: {
mutationObserver: true,
allowBackNavigation: true,
allowForwardNavigation: true,
allowDirectNavigation: true,
showActions: true,
showStatus: false,
hideSubmittedActions: false,
autoFocus: false,
keepCompletedNavigationState: true,
},
validation: {
native: true,
selector:
"input,select,textarea,button,monster-select,monster-password,monster-toggle-switch,monster-digits",
callback: null,
panels: {},
groups: {},
},
actions: {
beforeStepChange: null,
afterStepChange: null,
invalid: null,
complete: null,
},
});
}
static getTag() {
return "monster-wizard";
}
static getCSSStyleSheet() {
return [WizardStyleSheet];
}
[assembleMethodSymbol]() {
super[assembleMethodSymbol]();
this[currentPanelIndexSymbol] = -1;
this[idsSymbol] = {
panel: 0,
group: 0,
};
initControlReferences.call(this);
initEventHandler.call(this);
initOptionObserver.call(this);
ensureLabels.call(this);
queueMicrotask(() => {
this.refresh();
});
return this;
}
/**
* Rebuild the internal navigation structure from the slotted panels.
*
* @return {Wizard}
*/
refresh() {
const records = collectPanels.call(this);
const groups = buildGroups.call(this, records);
this[panelRecordsSymbol] = records;
this[groupsSymbol] = groups;
renderNavigation.call(this);
syncActionLabels.call(this);
if (!records.length) {
syncStatus.call(this);
return this;
}
const currentIndex =
this[currentPanelIndexSymbol] >= 0 &&
this[currentPanelIndexSymbol] < records.length
? this[currentPanelIndexSymbol]
: 0;
syncNavigationInteraction.call(this);
setActivePanel.call(this, currentIndex, { force: true });
fireCustomEvent(this, "monster-wizard-refresh", {
panelCount: records.length,
groupCount: groups.length,
});
return this;
}
/**
* Returns the current panel index.
*
* @return {number}
*/
getCurrentIndex() {
return this[currentPanelIndexSymbol];
}
/**
* Returns the current panel element or `null`.
*
* @return {HTMLElement|null}
*/
getCurrentPanel() {
const record = this[panelRecordsSymbol]?.[this[currentPanelIndexSymbol]];
return record?.element || null;
}
/**
* Navigate to the next panel.
*
* @return {Promise<boolean>}
*/
async next() {
return this.goTo(this[currentPanelIndexSymbol] + 1);
}
/**
* Navigate to the previous panel.
*
* @return {Promise<boolean>}
*/
async previous() {
return this.goTo(this[currentPanelIndexSymbol] - 1);
}
/**
* Navigate to the requested panel index.
*
* @param {number} targetIndex
* @param {Object} [options]
* @param {boolean} [options.force=false]
* @param {string} [options.reason="api"]
* @return {Promise<boolean>}
*/
async goTo(targetIndex, options = {}) {
if (typeof targetIndex !== "number" || Number.isNaN(targetIndex)) {
return false;
}
const records = this[panelRecordsSymbol] || [];
if (!records.length || targetIndex < 0 || targetIndex >= records.length) {
return false;
}
const currentIndex = this[currentPanelIndexSymbol];
if (targetIndex === currentIndex) {
return true;
}
const { force = false, reason = "api" } = options;
const direction =
currentIndex === -1
? "initial"
: targetIndex > currentIndex
? "forward"
: "backward";
const context = createTransitionContext.call(
this,
currentIndex,
targetIndex,
direction,
reason,
);
if (!force) {
const allowed = await allowTransition.call(this, context);
if (!allowed) {
return false;
}
}
setActivePanel.call(this, targetIndex, options);
return true;
}
/**
* Validates the current panel without changing the current position.
*
* @return {Promise<boolean>}
*/
async validateCurrent() {
const index = this[currentPanelIndexSymbol];
if (index < 0) {
return true;
}
const context = createTransitionContext.call(
this,
index,
Math.min(index + 1, (this[panelRecordsSymbol] || []).length - 1),
"forward",
"validate",
);
return runValidation.call(this, context);
}
/**
* Trigger the completion flow on the last panel.
*
* @return {Promise<boolean>}
*/
async submit() {
const index = this[currentPanelIndexSymbol];
const records = this[panelRecordsSymbol] || [];
if (index < 0 || index !== records.length - 1) {
return false;
}
const context = createTransitionContext.call(
this,
index,
index,
"submit",
"submit",
);
const valid = await runValidation.call(this, context);
if (!valid) {
return false;
}
const callback = this.getOption("actions.complete");
if (isFunction(callback)) {
const result = await Promise.resolve(callback.call(this, context));
if (result === false) {
return false;
}
}
fireCustomEvent(this, "monster-wizard-complete", context);
this[navigationElementSymbol]?.completeAll?.();
if (this[finalRecordSymbol]) {
showFinalPanel.call(this);
}
if (this.getOption("features.hideSubmittedActions") === true) {
this[controlElementSymbol]?.setAttribute(
"data-monster-state",
"completed",
);
}
this[submitButtonElementSymbol]?.removeState?.();
this[submitButtonElementSymbol]?.clearMessage?.();
this[submitButtonElementSymbol]?.setState?.("successful", 2000);
return true;
}
}
function initControlReferences() {
this[controlElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="control"]`,
);
this[navigationElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="navigation"]`,
);
this[slotElementSymbol] = this.shadowRoot.querySelector("slot");
this[previousButtonElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="previous"]`,
);
this[nextButtonElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="next"]`,
);
this[submitButtonElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="submit"]`,
);
this[statusElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="status"]`,
);
}
function initEventHandler() {
this[slotElementSymbol]?.addEventListener("slotchange", () => {
this.refresh();
});
this[navigationElementSymbol]?.addEventListener(
"monster-wizard-step-changed",
(event) => {
const mainIndex = event?.detail?.newIndex;
if (typeof mainIndex !== "number") {
return;
}
const targetRecord = this[groupsSymbol]?.[mainIndex]?.panels?.[0];
if (
!targetRecord ||
targetRecord.index === this[currentPanelIndexSymbol]
) {
return;
}
setActivePanel.call(this, targetRecord.index, {
force: true,
reason: "navigation",
});
},
);
this[navigationElementSymbol]?.addEventListener(
"monster-wizard-substep-changed",
(event) => {
const mainIndex = event?.detail?.mainIndex;
const subIndex = event?.detail?.subIndex;
if (typeof mainIndex !== "number" || typeof subIndex !== "number") {
return;
}
const targetRecord = this[groupsSymbol]?.[mainIndex]?.panels?.[subIndex];
if (
!targetRecord ||
targetRecord.index === this[currentPanelIndexSymbol]
) {
return;
}
setActivePanel.call(this, targetRecord.index, {
force: true,
reason: "navigation",
});
},
);
this[previousButtonElementSymbol]?.addEventListener("click", () => {
this.previous();
});
this[nextButtonElementSymbol]?.addEventListener("click", () => {
this.next();
});
this[submitButtonElementSymbol]?.addEventListener("click", () => {
this.submit();
});
}
function initOptionObserver() {
if (this[optionObserverSymbol]) {
return;
}
this[optionObserverSymbol] = true;
this.attachObserver(
new Observer(() => {
ensureLabels.call(this);
syncActionLabels.call(this);
syncNavigationInteraction.call(this);
syncActionState.call(this);
syncStatus.call(this);
}),
);
}
function syncActionLabels() {
const labels = this.getOption("labels");
if (this[previousButtonElementSymbol]) {
this[previousButtonElementSymbol].setOption(
"labels.button",
labels.previous || "Back",
);
}
if (this[nextButtonElementSymbol]) {
this[nextButtonElementSymbol].setOption(
"labels.button",
labels.next || "Next",
);
}
if (this[submitButtonElementSymbol]) {
this[submitButtonElementSymbol].setOption(
"labels.button",
labels.submit || "Finish",
);
}
}
function syncNavigationInteraction() {
const navigation = this[navigationElementSymbol];
if (!(navigation instanceof HTMLElement)) {
return;
}
const allowDirect = this.getOption("features.allowDirectNavigation");
navigation.style.pointerEvents = allowDirect ? "" : "none";
navigation.toggleAttribute("data-monster-direct-navigation", allowDirect);
}
function syncActionState() {
const records = this[panelRecordsSymbol] || [];
const currentIndex = this[currentPanelIndexSymbol];
const hasPanels = records.length > 0;
const atFirst = currentIndex <= 0;
const atLast = hasPanels && currentIndex === records.length - 1;
const finalVisible =
this[finalRecordSymbol]?.element?.hasAttribute?.("data-monster-active") ===
true;
const allowBack = this.getOption("features.allowBackNavigation");
const allowForward = this.getOption("features.allowForwardNavigation");
const showActions = this.getOption("features.showActions");
const showPrevious =
showActions && !finalVisible && allowBack === true && !atFirst;
this[controlElementSymbol]?.toggleAttribute(
"data-monster-show-actions",
showActions,
);
if (this[previousButtonElementSymbol]) {
this[previousButtonElementSymbol].hidden = !showPrevious;
this[previousButtonElementSymbol].style.display = showPrevious
? ""
: "none";
this[previousButtonElementSymbol].disabled =
!hasPanels || atFirst || allowBack !== true;
}
if (this[nextButtonElementSymbol]) {
this[nextButtonElementSymbol].hidden =
!showActions || atLast || finalVisible;
this[nextButtonElementSymbol].style.display =
!showActions || atLast || finalVisible ? "none" : "";
this[nextButtonElementSymbol].disabled =
!hasPanels || atLast || allowForward !== true;
}
if (this[submitButtonElementSymbol]) {
this[submitButtonElementSymbol].hidden =
!showActions || !atLast || finalVisible;
this[submitButtonElementSymbol].style.display =
!showActions || !atLast || finalVisible ? "none" : "";
this[submitButtonElementSymbol].disabled = !hasPanels || !atLast;
}
}
function syncStatus() {
const statusElement = this[statusElementSymbol];
if (!(statusElement instanceof HTMLElement)) {
return;
}
if (this.getOption("features.showStatus") !== true) {
statusElement.hidden = true;
statusElement.textContent = "";
return;
}
statusElement.hidden = false;
const total = this[panelRecordsSymbol]?.length || 0;
const finalVisible =
this[finalRecordSymbol]?.element?.hasAttribute?.("data-monster-active") ===
true;
if (total === 0 || this[currentPanelIndexSymbol] < 0 || finalVisible) {
statusElement.textContent = "";
return;
}
const template =
this.getOption("labels.stepStatus") || "Step {current} of {total}";
statusElement.textContent = template
.replace("{current}", String(this[currentPanelIndexSymbol] + 1))
.replace("{total}", String(total));
}
function ensureLabels() {
const current = this.getOption("labels");
const fallback = getTranslations();
if (!isObject(current)) {
this.setOption("labels", fallback);
return;
}
this.setOption("labels", Object.assign({}, fallback, current));
}
function collectPanels() {
const elements = Array.from(getSlottedElements.call(this) || []).filter(
(element) => element instanceof HTMLElement,
);
const finalIndex = elements.length - 1;
const hasImplicitFinal =
finalIndex >= 0 &&
elements.length > 1 &&
elements[finalIndex] instanceof HTMLElement &&
elements[finalIndex].hasAttribute(
"data-monster-option-navigation-label",
) === false;
this[finalRecordSymbol] = null;
const stepElements = elements.filter((element, index) => {
if (hasImplicitFinal && index === finalIndex) {
return false;
}
return element.hasAttribute("data-monster-option-navigation-label");
});
const records = stepElements.map((element, index) => {
const mainLabel =
element.getAttribute("data-monster-option-navigation-label")?.trim() ||
`Step ${index + 1}`;
const subLabel =
element
.getAttribute("data-monster-option-navigation-sub-label")
?.trim() || null;
const panelId =
element.getAttribute("data-monster-option-navigation-id")?.trim() ||
slugify(
subLabel
? `${mainLabel}-${subLabel}-${index + 1}`
: `${mainLabel}-${index + 1}`,
) ||
`panel-${++this[idsSymbol].panel}`;
const groupBase =
element.getAttribute("data-monster-option-navigation-group")?.trim() ||
mainLabel;
const groupId = slugify(groupBase) || `group-${++this[idsSymbol].group}`;
element.setAttribute("data-monster-wizard-panel-id", panelId);
element.setAttribute("data-monster-wizard-group-id", groupId);
element.setAttribute("data-monster-role", "panel");
normalizePanelHeading(element);
return {
element,
index,
panelId,
groupId,
mainLabel,
subLabel,
mainIndex: -1,
subIndex: -1,
};
});
const finalElement = elements[finalIndex];
if (hasImplicitFinal && finalElement instanceof HTMLElement) {
finalElement.setAttribute("data-monster-role", "panel");
normalizePanelHeading(finalElement);
finalElement.hidden = true;
finalElement.removeAttribute("data-monster-active");
this[finalRecordSymbol] = {
element: finalElement,
panelId:
finalElement
.getAttribute("data-monster-option-navigation-id")
?.trim() || "final",
};
}
for (const element of elements) {
applyEmbeddedFieldSetContext(element);
}
return records;
}
function applyEmbeddedFieldSetContext(element) {
if (!(element instanceof HTMLElement)) {
return;
}
for (const fieldSet of element.querySelectorAll("monster-field-set")) {
fieldSet.setAttribute("data-monster-context", "wizard");
}
}
function buildGroups(records) {
const groups = [];
const map = new Map();
for (const record of records) {
let group = map.get(record.groupId);
if (!group) {
group = {
id: record.groupId,
label: record.mainLabel,
mainIndex: groups.length,
panels: [],
};
map.set(record.groupId, group);
groups.push(group);
}
record.mainIndex = group.mainIndex;
record.subIndex = group.panels.length;
group.panels.push(record);
}
return groups;
}
function renderNavigation() {
const navigation = this[navigationElementSymbol];
if (!(navigation instanceof HTMLElement)) {
return;
}
navigation.innerHTML = "";
const list = document.createElement("ol");
list.className = "wizard-steps";
for (const group of this[groupsSymbol] || []) {
const step = document.createElement("li");
step.className = "step";
const label = document.createElement("span");
label.setAttribute("data-monster-role", "step-label");
label.textContent = group.label;
step.appendChild(label);
if (group.panels.length > 1 || group.panels[0]?.subLabel) {
const subList = document.createElement("ul");
for (const panel of group.panels) {
const item = document.createElement("li");
item.textContent = panel.subLabel || `Step ${panel.subIndex + 1}`;
subList.appendChild(item);
}
step.appendChild(subList);
}
list.appendChild(step);
}
navigation.appendChild(list);
navigation.setOption("actions.beforeStepChange", (fromIndex, toIndex) => {
return handleNavigationRequest.call(this, fromIndex, toIndex);
});
queueMicrotask(() => {
const currentIndex =
this[currentPanelIndexSymbol] >= 0 ? this[currentPanelIndexSymbol] : 0;
const record = this[panelRecordsSymbol]?.[currentIndex];
if (record) {
navigation.activate(record.mainIndex, record.subIndex, { force: true });
}
});
}
async function handleNavigationRequest(fromIndex, toIndex) {
if (this.getOption("features.allowDirectNavigation") !== true) {
return false;
}
const targetPanel = this[groupsSymbol]?.[toIndex]?.panels?.[0];
if (!targetPanel) {
return false;
}
const currentIndex = this[currentPanelIndexSymbol];
if (currentIndex === -1) {
return true;
}
const targetIndex = targetPanel.index;
if (targetIndex === currentIndex) {
return false;
}
const direction = targetIndex > currentIndex ? "forward" : "backward";
const context = createTransitionContext.call(
this,
currentIndex,
targetIndex,
direction,
"navigation",
);
return allowTransition.call(this, context);
}
function setActivePanel(targetIndex, options = {}) {
const records = this[panelRecordsSymbol] || [];
const previousIndex = this[currentPanelIndexSymbol];
const record = records[targetIndex];
if (!record) {
return;
}
for (const panelRecord of records) {
const active = panelRecord.index === targetIndex;
panelRecord.element.hidden = !active;
panelRecord.element.toggleAttribute("data-monster-active", active);
panelRecord.element.toggleAttribute(
"data-monster-completed",
this.getOption("features.keepCompletedNavigationState") === true &&
panelRecord.index < targetIndex,
);
}
if (this[finalRecordSymbol]?.element) {
this[finalRecordSymbol].element.hidden = true;
this[finalRecordSymbol].element.removeAttribute("data-monster-active");
}
this[currentPanelIndexSymbol] = targetIndex;
if (this.getOption("features.keepCompletedNavigationState") === true) {
this[navigationElementSymbol]?.activate(record.mainIndex, record.subIndex, {
force: true,
});
} else {
this[navigationElementSymbol]?.activate(record.mainIndex, record.subIndex, {
force: true,
});
}
syncActionState.call(this);
syncStatus.call(this);
clearActionFeedback.call(this);
if (this.getOption("features.autoFocus") === true) {
queueMicrotask(() => {
focusFirstControl(record.element);
});
}
fireCustomEvent(this, "monster-wizard-panel-change", {
panelIndex: targetIndex,
previousPanelIndex: previousIndex,
panelId: record.panelId,
groupId: record.groupId,
panel: record.element,
options,
});
const callback = this.getOption("actions.afterStepChange");
if (isFunction(callback)) {
callback.call(
this,
createTransitionContext.call(
this,
previousIndex,
targetIndex,
targetIndex > previousIndex ? "forward" : "backward",
options.reason || "internal",
),
);
}
}
function showFinalPanel() {
const finalRecord = this[finalRecordSymbol];
if (!finalRecord?.element) {
return;
}
for (const panelRecord of this[panelRecordsSymbol] || []) {
panelRecord.element.hidden = true;
panelRecord.element.removeAttribute("data-monster-active");
}
finalRecord.element.hidden = false;
finalRecord.element.setAttribute("data-monster-active", "");
this[currentPanelIndexSymbol] = -1;
syncActionState.call(this);
syncStatus.call(this);
clearActionFeedback.call(this);
fireCustomEvent(this, "monster-wizard-final-panel-show", {
panelId: finalRecord.panelId,
panel: finalRecord.element,
});
}
async function allowTransition(context) {
const currentIndex = this[currentPanelIndexSymbol];
fireCustomEvent(this, "monster-wizard-change-request", context);
if (context.direction === "backward") {
if (this.getOption("features.allowBackNavigation") !== true) {
return false;
}
} else if (context.direction === "forward") {
if (this.getOption("features.allowForwardNavigation") !== true) {
return false;
}
if (
this.getOption("features.allowDirectNavigation") !== true &&
context.toIndex > currentIndex + 1
) {
return false;
}
const valid = await runValidation.call(this, context);
if (!valid) {
return false;
}
}
const beforeStepChange = this.getOption("actions.beforeStepChange");
if (isFunction(beforeStepChange)) {
const result = await Promise.resolve(beforeStepChange.call(this, context));
if (result === false) {
return false;
}
}
return true;
}
async function runValidation(context) {
let valid = true;
let message = null;
if (this.getOption("validation.native") === true) {
valid = validatePanelElement.call(this, context.fromPanel) && valid;
}
const groupCallback = this.getOption(
`validation.groups.${context.fromGroup?.id || ""}`,
);
if (isFunction(groupCallback)) {
const result = await Promise.resolve(groupCallback.call(this, context));
valid = normalizeValidationResult(result, valid);
message = extractValidationMessage(result) || message;
}
const panelCallback = this.getOption(
`validation.panels.${context.fromPanelId || ""}`,
);
if (isFunction(panelCallback)) {
const result = await Promise.resolve(panelCallback.call(this, context));
valid = normalizeValidationResult(result, valid);
message = extractValidationMessage(result) || message;
}
const globalCallback = this.getOption("validation.callback");
if (isFunction(globalCallback)) {
const result = await Promise.resolve(globalCallback.call(this, context));
valid = normalizeValidationResult(result, valid);
message = extractValidationMessage(result) || message;
}
if (!valid) {
const actionButton = getActionButtonForContext.call(this, context);
const fallbackMessage = this.getOption("labels.validationFailed");
showActionError.call(
this,
actionButton,
message ||
fallbackMessage ||
"Please check the current step before continuing.",
);
fireCustomEvent(this, "monster-wizard-invalid", context);
const invalid = this.getOption("actions.invalid");
if (isFunction(invalid)) {
invalid.call(this, Object.assign({}, context, { message }));
}
}
fireCustomEvent(this, "monster-wizard-validation", {
...context,
valid,
message,
});
return valid;
}
function validatePanelElement(panel) {
if (!(panel instanceof HTMLElement)) {
return true;
}
let valid = true;
const formContainers = panel.querySelectorAll("form, monster-form");
for (const element of formContainers) {
if (typeof element.reportValidity === "function") {
valid = element.reportValidity() !== false && valid;
}
}
const selector = this.getOption("validation.selector");
if (typeof selector !== "string" || selector.trim() === "") {
return valid;
}
const controls = panel.querySelectorAll(selector);
for (const element of controls) {
if (element.closest("form, monster-form")) {
continue;
}
if (typeof element.reportValidity === "function") {
valid = element.reportValidity() !== false && valid;
}
}
return valid;
}
function createTransitionContext(fromIndex, toIndex, direction, reason) {
const records = this[panelRecordsSymbol] || [];
const groups = this[groupsSymbol] || [];
const fromRecord =
fromIndex >= 0 && fromIndex < records.length ? records[fromIndex] : null;
const toRecord =
toIndex >= 0 && toIndex < records.length ? records[toIndex] : null;
return {
wizard: this,
reason,
direction,
fromIndex,
toIndex,
fromPanelId: fromRecord?.panelId || null,
toPanelId: toRecord?.panelId || null,
fromPanel: fromRecord?.element || null,
toPanel: toRecord?.element || null,
fromGroup: fromRecord ? groups[fromRecord.mainIndex] : null,
toGroup: toRecord ? groups[toRecord.mainIndex] : null,
actionButton: getActionButtonForDirection.call(this, direction),
};
}
function getActionButtonForContext(context) {
if (context.direction === "submit") {
return this[submitButtonElementSymbol] || null;
}
return this[nextButtonElementSymbol] || null;
}
function getActionButtonForDirection(direction) {
if (direction === "submit") {
return this[submitButtonElementSymbol] || null;
}
if (direction === "backward") {
return this[previousButtonElementSymbol] || null;
}
return this[nextButtonElementSymbol] || null;
}
function clearActionFeedback() {
for (const button of [
this[previousButtonElementSymbol],
this[nextButtonElementSymbol],
this[submitButtonElementSymbol],
]) {
button?.removeState?.();
button?.hideMessage?.();
button?.clearMessage?.();
}
}
function showActionError(button, message) {
if (!button) {
return;
}
button.removeState?.();
button.hideMessage?.();
button.clearMessage?.();
button.setState?.("failed", 2500);
button.setMessage?.(message);
button.showMessage?.(3500);
}
function normalizeValidationResult(result, valid) {
if (result === false) {
return false;
}
if (typeof result === "string") {
return false;
}
return valid;
}
function extractValidationMessage(result) {
if (typeof result === "string" && result.trim() !== "") {
return result.trim();
}
if (
result &&
typeof result === "object" &&
typeof result.message === "string" &&
result.message.trim() !== ""
) {
return result.message.trim();
}
return null;
}
function focusFirstControl(panel) {
if (!(panel instanceof HTMLElement)) {
return;
}
const target = panel.querySelector(
'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
target?.focus?.();
}
function normalizePanelHeading(panel) {
if (!(panel instanceof HTMLElement)) {
return;
}
const heading = panel.querySelector("h1, h2, h3, h4, h5, h6");
if (!(heading instanceof HTMLElement)) {
return;
}
heading.style.marginTop = "0";
heading.style.paddingTop = "0";
}
function getTranslations() {
const locale = getLocaleOfDocument();
switch (locale.language) {
case "de":
return {
previous: "Zurueck",
next: "Weiter",
submit: "Abschliessen",
stepStatus: "Schritt {current} von {total}",
validationFailed: "Bitte pruefen Sie den aktuellen Schritt.",
};
case "fr":
return {
previous: "Retour",
next: "Suivant",
submit: "Terminer",
stepStatus: "Etape {current} sur {total}",
validationFailed: "Veuillez verifier l'etape actuelle.",
};
case "es":
return {
previous: "Atras",
next: "Siguiente",
submit: "Finalizar",
stepStatus: "Paso {current} de {total}",
validationFailed: "Revise el paso actual.",
};
case "zh":
return {
previous: "返回",
next: "下一步",
submit: "完成",
stepStatus: "第 {current} / {total} 步",
validationFailed: "请检查当前步骤。",
};
case "hi":
return {
previous: "वापस",
next: "आगे",
submit: "पूरा करें",
stepStatus: "चरण {current} / {total}",
validationFailed: "कृपया वर्तमान चरण जांचें।",
};
case "bn":
return {
previous: "পেছনে",
next: "পরবর্তী",
submit: "সমাপ্ত",
stepStatus: "ধাপ {current} / {total}",
validationFailed: "অনুগ্রহ করে বর্তমান ধাপটি পরীক্ষা করুন।",
};
case "pt":
return {
previous: "Voltar",
next: "Avancar",
submit: "Concluir",
stepStatus: "Passo {current} de {total}",
validationFailed: "Verifique a etapa atual.",
};
case "ru":
return {
previous: "Назад",
next: "Далее",
submit: "Завершить",
stepStatus: "Шаг {current} из {total}",
validationFailed: "Проверьте текущий шаг.",
};
case "ja":
return {
previous: "戻る",
next: "次へ",
submit: "完了",
stepStatus: "{total} 件中 {current} ステップ",
validationFailed: "現在のステップを確認してください。",
};
case "pa":
return {
previous: "ਵਾਪਸ",
next: "ਅੱਗੇ",
submit: "ਪੂਰਾ ਕਰੋ",
stepStatus: "ਕਦਮ {current} / {total}",
validationFailed: "ਕਿਰਪਾ ਕਰਕੇ ਮੌਜੂਦਾ ਕਦਮ ਦੀ ਜਾਂਚ ਕਰੋ।",
};
case "mr":
return {
previous: "मागे",
next: "पुढे",
submit: "पूर्ण करा",
stepStatus: "पायरी {current} / {total}",
validationFailed: "कृपया सद्य पायरी तपासा.",
};
case "it":
return {
previous: "Indietro",
next: "Avanti",
submit: "Concludi",
stepStatus: "Passo {current} di {total}",
validationFailed: "Controlla il passaggio corrente.",
};
case "nl":
return {
previous: "Terug",
next: "Volgende",
submit: "Voltooien",
stepStatus: "Stap {current} van {total}",
validationFailed: "Controleer de huidige stap.",
};
case "sv":
return {
previous: "Tillbaka",
next: "Nasta",
submit: "Slutfor",
stepStatus: "Steg {current} av {total}",
validationFailed: "Kontrollera det aktuella steget.",
};
case "pl":
return {
previous: "Wstecz",
next: "Dalej",
submit: "Zakoncz",
stepStatus: "Krok {current} z {total}",
validationFailed: "Sprawdz biezacy krok.",
};
case "da":
return {
previous: "Tilbage",
next: "Naeste",
submit: "Afslut",
stepStatus: "Trin {current} af {total}",
validationFailed: "Kontroller det aktuelle trin.",
};
case "fi":
return {
previous: "Takaisin",
next: "Seuraava",
submit: "Valmis",
stepStatus: "Vaihe {current} / {total}",
validationFailed: "Tarkista nykyinen vaihe.",
};
case "no":
return {
previous: "Tilbake",
next: "Neste",
submit: "Fullfor",
stepStatus: "Trinn {current} av {total}",
validationFailed: "Kontroller gjeldende trinn.",
};
case "cs":
return {
previous: "Zpet",
next: "Dalsi",
submit: "Dokoncit",
stepStatus: "Krok {current} z {total}",
validationFailed: "Zkontrolujte aktualni krok.",
};
default:
return {
previous: "Back",
next: "Next",
submit: "Finish",
stepStatus: "Step {current} of {total}",
validationFailed: "Please check the current step before continuing.",
};
}
}
function slugify(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function getTemplate() {
return `
<div data-monster-role="control" part="control">
<div data-monster-role="body" part="body">
<monster-wizard-navigation data-monster-role="navigation" part="navigation"></monster-wizard-navigation>
<div data-monster-role="content" part="content">
<div data-monster-role="status" part="status" aria-live="polite"></div>
<div data-monster-role="panel-slot" part="panel-slot">
<slot></slot>
</div>
<monster-button-bar data-monster-role="actions" part="actions">
<monster-message-state-button data-monster-role="previous" part="previous"></monster-message-state-button>
<monster-message-state-button data-monster-role="next" part="next"></monster-message-state-button>
<monster-message-state-button data-monster-role="submit" part="submit"></monster-message-state-button>
</monster-button-bar>
</div>
</div>
</div>`;
}
registerCustomElement(Wizard);