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
RapidContext
Access · Discovery · Insight
www.rapidcontext.com