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