Source RapidContext_Widget_Wizard.js

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