Source RapidContext_Widget_Wizard.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2024 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15// Namespace initialization
16if (typeof(RapidContext) == "undefined") {
17    RapidContext = {};
18}
19RapidContext.Widget = RapidContext.Widget || { Classes: {} };
20
21/**
22 * Creates a new wizard widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to false
27 * @param {...Pane} [child] the child Pane widgets
28 *
29 * @return {Widget} the widget DOM node
30 *
31 * @class The wizard widget class. Used to provide a sequence of pages, where
32 *     the user can step forward and backward with buttons. Internally it uses
33 *     a `<div>` HTML element containing Pane widgets that are hidden and shown
34 *     according to the page transitions.
35 * @extends RapidContext.Widget
36 *
37 * @example <caption>JavaScript</caption>
38 * let page1 = RapidContext.Widget.Pane({ pageTitle: "The first step" });
39 * ...
40 * let page2 = RapidContext.Widget.Pane({ pageTitle: "The second step" });
41 * ...
42 * let attrs = { style: { width: "100%", height: "100%" } };
43 * let exampleWizard = RapidContext.Widget.Wizard(attrs, page1, page2);
44 * let exampleDialog = RapidContext.Widget.Dialog({ title: "Example Dialog" }, exampleWizard);
45 *
46 * @example <caption>User Interface XML</caption>
47 * <Dialog id="exampleDialog" title="Example Dialog" w="80%" h="50%">
48 *   <Wizard id="exampleWizard" style="width: 100%; height: 100%;">
49 *     <Pane pageTitle="The first step">
50 *       ...
51 *     </Pane>
52 *     <Pane pageTitle="The second step">
53 *       ...
54 *     </Pane>
55 *   </Wizard>
56 * </Dialog>
57 */
58RapidContext.Widget.Wizard = function (attrs/*, ... */) {
59    let o = RapidContext.UI.DIV(attrs);
60    RapidContext.Widget._widgetMixin(o, RapidContext.Widget.Wizard);
61    o.addClass("widgetWizard");
62    o._selectedIndex = -1;
63    o.append(RapidContext.UI.H3({ "class": "widgetWizardTitle" }));
64    let bCancel = RapidContext.Widget.Button(
65        { icon: "fa fa-lg fa-times", "class": "mr-2", "data-action": "cancel" },
66        "Cancel"
67    );
68    let bPrev = RapidContext.Widget.Button(
69        { icon: "fa fa-lg fa-caret-left", "class": "mr-2", "data-action": "previous" },
70        "Previous"
71    );
72    let bNext = RapidContext.Widget.Button(
73        { "data-action": "next" },
74        "Next",
75        RapidContext.Widget.Icon({ class: "fa fa-lg fa-caret-right" })
76    );
77    let bDone = RapidContext.Widget.Button(
78        { icon: "fa fa-lg fa-check", highlight: true, "data-action": "done" },
79        "Finish"
80    );
81    bCancel.hide();
82    let divAttrs = { "class": "widgetWizardButtons" };
83    o.append(RapidContext.UI.DIV(divAttrs, bCancel, bPrev, bNext, bDone));
84    o._updateStatus();
85    o.setAttrs(attrs);
86    o.addAll(Array.from(arguments).slice(1));
87    o.on("click", ".widgetWizardButtons [data-action]", o._handleBtnClick);
88    return o;
89};
90
91// Register widget class
92RapidContext.Widget.Classes.Wizard = RapidContext.Widget.Wizard;
93
94/**
95 * Emitted when a page transition is performed.
96 *
97 * @name RapidContext.Widget.Wizard#onchange
98 * @event
99 */
100
101/**
102 * Emitted when the user selects to cancel the page flow.
103 *
104 * @name RapidContext.Widget.Wizard#oncancel
105 * @event
106 */
107
108/**
109 * Emitted when the user has completed the page flow.
110 *
111 * @name RapidContext.Widget.Wizard#onclose
112 * @event
113 */
114
115/**
116 * Handles button click events.
117 *
118 * @param {Event} evt the DOM Event object
119 */
120RapidContext.Widget.Wizard.prototype._handleBtnClick = function (evt) {
121    let action = evt.delegateTarget.dataset.action;
122    if (typeof(this[action]) == "function") {
123        this[action]();
124    }
125};
126
127/**
128 * Returns an array with all child pane widgets. Note that the array
129 * is a real JavaScript array, not a dynamic NodeList.
130 *
131 * @return {Array} the array of child wizard page widgets
132 */
133RapidContext.Widget.Wizard.prototype.getChildNodes = function () {
134    return Array.from(this.childNodes).slice(2);
135};
136
137/**
138 * Adds a single child page widget to this widget. The child widget should be a
139 * `RapidContext.Widget.Pane` widget, or a new one will be created where it
140 * will be added.
141 *
142 * @param {Widget} child the page widget to add
143 */
144RapidContext.Widget.Wizard.prototype.addChildNode = function (child) {
145    if (!RapidContext.Widget.isWidget(child, "Pane")) {
146        child = RapidContext.Widget.Pane(null, child);
147    }
148    child.style.width = "100%";
149    child.style.height = "calc(100% - 65px)";
150    child.hide();
151    this.append(child);
152    child.style.position = "absolute";
153    // TODO: remove hard-coded size here...
154    MochiKit.Style.setElementPosition(child, { x: 0, y: 24 });
155    if (this.getChildNodes().length == 1) {
156        this.activatePage(0);
157    } else {
158        this._updateStatus();
159    }
160};
161
162// TODO: handle removes by possibly selecting new page...
163
164/**
165 * Updates the wizard status indicators, such as the title and the
166 * current buttons.
167 */
168RapidContext.Widget.Wizard.prototype._updateStatus = function () {
169    let h3 = this.childNodes[0];
170    let bCancel = this.childNodes[1].childNodes[0];
171    let bPrev = this.childNodes[1].childNodes[1];
172    let bNext = this.childNodes[1].childNodes[2];
173    let bDone = this.childNodes[1].childNodes[3];
174    let page = this.activePage();
175    let status = RapidContext.Widget.Pane.FORWARD;
176    let title = null;
177    let info = "(No pages available)";
178    let icon = "";
179    if (page != null) {
180        status = page.pageStatus || RapidContext.Widget.Pane.ANY;
181        title = page.pageTitle;
182        info = " (Step " + (this._selectedIndex + 1) + " of " +
183               this.getChildNodes().length + ")";
184    }
185    if (status === RapidContext.Widget.Pane.WORKING) {
186        bCancel.show();
187        bPrev.hide();
188        icon = { ref: "LOADING", "class": "widgetWizardWait" };
189        icon = RapidContext.Widget.Icon(icon);
190    } else {
191        bCancel.hide();
192        bPrev.show();
193    }
194    if (this._selectedIndex >= this.getChildNodes().length - 1) {
195        bNext.hide();
196        bDone.show();
197    } else {
198        bNext.show();
199        bDone.hide();
200    }
201    bPrev.setAttrs({ disabled: (this._selectedIndex <= 0) || !status.previous });
202    bNext.setAttrs({ disabled: !status.next });
203    bDone.setAttrs({ disabled: !status.next });
204    info = RapidContext.UI.SPAN({ "class": "widgetWizardInfo" }, info);
205    h3.innerHTML = "";
206    h3.append(icon, title, info);
207};
208
209/**
210 * Returns the active page.
211 *
212 * @return {Widget} the active page, or
213 *         null if no pages have been added
214 */
215RapidContext.Widget.Wizard.prototype.activePage = function () {
216    if (this._selectedIndex >= 0) {
217        return this.childNodes[this._selectedIndex + 2];
218    } else {
219        return null;
220    }
221};
222
223/**
224 * Returns the active page index.
225 *
226 * @return the active page index, or
227 *         -1 if no page is active
228 */
229RapidContext.Widget.Wizard.prototype.activePageIndex = function () {
230    return this._selectedIndex;
231};
232
233/**
234 * Activates a new page and sends the `onchange` signal. If the page is moved
235 * forward, the old page must pass a form validation check, or nothing will
236 * happen.
237 *
238 * @param {number|Widget} indexOrPage the page index or page DOM node
239 *
240 * @see #next
241 * @see #previous
242 */
243RapidContext.Widget.Wizard.prototype.activatePage = function (indexOrPage) {
244    let index, page;
245    if (typeof(indexOrPage) == "number") {
246        index = indexOrPage;
247        page = this.childNodes[index + 2];
248    } else {
249        page = indexOrPage;
250        index = Array.from(this.childNodes).indexOf(page, 2) - 2;
251    }
252    if (index < 0 || index >= this.getChildNodes().length) {
253        throw new RangeError("Page index out of bounds: " + index);
254    }
255    let oldIndex = this._selectedIndex;
256    let oldPage = this.activePage();
257    if (oldPage != null && oldPage !== page) {
258        if (!oldPage._handleExit({ hide: false, validate: this._selectedIndex < index })) {
259            // Old page blocked page transition
260            return;
261        }
262    }
263    this._selectedIndex = index;
264    this._updateStatus();
265    if (oldPage != null && oldPage !== page) {
266        let dim = MochiKit.Style.getElementDimensions(this);
267        let offset = (oldIndex < index) ? dim.w : -dim.w;
268        MochiKit.Style.setElementPosition(page, { x: offset });
269        page._handleEnter({ validateReset: true });
270        let cleanup = function () {
271            oldPage.hide();
272            MochiKit.Style.setElementPosition(oldPage, { x: 0 });
273        };
274        let opts = { duration: 0.5, x: -offset, afterFinish: cleanup };
275        MochiKit.Visual.Move(oldPage, opts);
276        MochiKit.Visual.Move(page, opts);
277    } else {
278        page._handleEnter({ validateReset: true });
279    }
280    let detail = { index: index, page: page };
281    this.emit("change", { detail: detail });
282};
283
284/**
285 * Cancels the active page operation. This method will also reset the page
286 * status of the currently active page to `ANY`. This method is triggered when
287 * the user presses the "Cancel" button.
288 *
289 * @see RapidContext.Widget.Pane.ANY
290 */
291RapidContext.Widget.Wizard.prototype.cancel = function () {
292    let page = this.activePage();
293    page.setAttrs({ pageStatus: RapidContext.Widget.Pane.ANY });
294    this.emit("cancel");
295};
296
297/**
298 * Moves the wizard backward to the previous page and sends the `onchange`
299 * signal. This method is triggered when the user presses the "Previous"
300 * button.
301 */
302RapidContext.Widget.Wizard.prototype.previous = function () {
303    if (this._selectedIndex > 0) {
304        this.activatePage(this._selectedIndex - 1);
305    }
306};
307
308/**
309 * Moves the wizard forward to the next page and sends the `onchange` signal.
310 * The page will not be changed if the active page fails a validation check.
311 * This method is triggered when the user presses the "Next" button.
312 */
313RapidContext.Widget.Wizard.prototype.next = function () {
314    if (this._selectedIndex < this.getChildNodes().length - 1) {
315        this.activatePage(this._selectedIndex + 1);
316    }
317};
318
319/**
320 * Sends the wizard `onclose` signal. This method is triggered when the user
321 * presses the "Finish" button.
322 */
323RapidContext.Widget.Wizard.prototype.done = function () {
324    let page = this.activePage();
325    if (page != null) {
326        if (!page._handleExit({ validate: true })) {
327            // Page blocked wizard completion
328            return;
329        }
330    }
331    this.emit("close");
332};
333