1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2025 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 const 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 const bCancel = RapidContext.Widget.Button(
65 { icon: "fa fa-lg fa-times", "class": "mr-2", "data-action": "cancel" },
66 "Cancel"
67 );
68 const bPrev = RapidContext.Widget.Button(
69 { icon: "fa fa-lg fa-caret-left", "class": "mr-2", "data-action": "previous" },
70 "Previous"
71 );
72 const bNext = RapidContext.Widget.Button(
73 { "data-action": "next" },
74 "Next",
75 RapidContext.Widget.Icon({ class: "fa fa-lg fa-caret-right" })
76 );
77 const bDone = RapidContext.Widget.Button(
78 { icon: "fa fa-lg fa-check", highlight: true, "data-action": "done" },
79 "Finish"
80 );
81 bCancel.hide();
82 const 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 const 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 const h3 = this.childNodes[0];
170 const bCancel = this.childNodes[1].childNodes[0];
171 const bPrev = this.childNodes[1].childNodes[1];
172 const bNext = this.childNodes[1].childNodes[2];
173 const bDone = this.childNodes[1].childNodes[3];
174 const 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 ${this.getChildNodes().length})`;
183 }
184 if (status === RapidContext.Widget.Pane.WORKING) {
185 bCancel.show();
186 bPrev.hide();
187 icon = { ref: "LOADING", "class": "widgetWizardWait" };
188 icon = RapidContext.Widget.Icon(icon);
189 } else {
190 bCancel.hide();
191 bPrev.show();
192 }
193 if (this._selectedIndex >= this.getChildNodes().length - 1) {
194 bNext.hide();
195 bDone.show();
196 } else {
197 bNext.show();
198 bDone.hide();
199 }
200 bPrev.setAttrs({ disabled: (this._selectedIndex <= 0) || !status.previous });
201 bNext.setAttrs({ disabled: !status.next });
202 bDone.setAttrs({ disabled: !status.next });
203 info = RapidContext.UI.SPAN({ "class": "widgetWizardInfo" }, info);
204 h3.innerHTML = "";
205 h3.append(icon, title, info);
206};
207
208/**
209 * Returns the active page.
210 *
211 * @return {Widget} the active page, or
212 * null if no pages have been added
213 */
214RapidContext.Widget.Wizard.prototype.activePage = function () {
215 if (this._selectedIndex >= 0) {
216 return this.childNodes[this._selectedIndex + 2];
217 } else {
218 return null;
219 }
220};
221
222/**
223 * Returns the active page index.
224 *
225 * @return the active page index, or
226 * -1 if no page is active
227 */
228RapidContext.Widget.Wizard.prototype.activePageIndex = function () {
229 return this._selectedIndex;
230};
231
232/**
233 * Activates a new page and sends the `onchange` signal. If the page is moved
234 * forward, the old page must pass a form validation check, or nothing will
235 * happen.
236 *
237 * @param {number|Widget} indexOrPage the page index or page DOM node
238 *
239 * @see #next
240 * @see #previous
241 */
242RapidContext.Widget.Wizard.prototype.activatePage = function (indexOrPage) {
243 let index, page;
244 if (typeof(indexOrPage) == "number") {
245 index = indexOrPage;
246 page = this.childNodes[index + 2];
247 } else {
248 page = indexOrPage;
249 index = Array.from(this.childNodes).indexOf(page, 2) - 2;
250 }
251 if (index < 0 || index >= this.getChildNodes().length) {
252 throw new RangeError(`Page index out of bounds: ${index}`);
253 }
254 const oldIndex = this._selectedIndex;
255 const oldPage = this.activePage();
256 if (oldPage != null && oldPage !== page) {
257 if (!oldPage._handleExit({ hide: false, validate: this._selectedIndex < index })) {
258 // Old page blocked page transition
259 return;
260 }
261 }
262 this._selectedIndex = index;
263 this._updateStatus();
264 if (oldPage != null && oldPage !== page) {
265 const dim = MochiKit.Style.getElementDimensions(this);
266 const offset = (oldIndex < index) ? dim.w : -dim.w;
267 MochiKit.Style.setElementPosition(page, { x: offset });
268 page._handleEnter({ validateReset: true });
269 const cleanup = function () {
270 oldPage.hide();
271 MochiKit.Style.setElementPosition(oldPage, { x: 0 });
272 };
273 const opts = { duration: 0.5, x: -offset, afterFinish: cleanup };
274 MochiKit.Visual.Move(oldPage, opts);
275 MochiKit.Visual.Move(page, opts);
276 } else {
277 page._handleEnter({ validateReset: true });
278 }
279 const detail = { index: index, page: page };
280 this.emit("change", { detail: detail });
281};
282
283/**
284 * Cancels the active page operation. This method will also reset the page
285 * status of the currently active page to `ANY`. This method is triggered when
286 * the user presses the "Cancel" button.
287 *
288 * @see RapidContext.Widget.Pane.ANY
289 */
290RapidContext.Widget.Wizard.prototype.cancel = function () {
291 const page = this.activePage();
292 page.setAttrs({ pageStatus: RapidContext.Widget.Pane.ANY });
293 this.emit("cancel");
294};
295
296/**
297 * Moves the wizard backward to the previous page and sends the `onchange`
298 * signal. This method is triggered when the user presses the "Previous"
299 * button.
300 */
301RapidContext.Widget.Wizard.prototype.previous = function () {
302 if (this._selectedIndex > 0) {
303 this.activatePage(this._selectedIndex - 1);
304 }
305};
306
307/**
308 * Moves the wizard forward to the next page and sends the `onchange` signal.
309 * The page will not be changed if the active page fails a validation check.
310 * This method is triggered when the user presses the "Next" button.
311 */
312RapidContext.Widget.Wizard.prototype.next = function () {
313 if (this._selectedIndex < this.getChildNodes().length - 1) {
314 this.activatePage(this._selectedIndex + 1);
315 }
316};
317
318/**
319 * Sends the wizard `onclose` signal. This method is triggered when the user
320 * presses the "Finish" button.
321 */
322RapidContext.Widget.Wizard.prototype.done = function () {
323 const page = this.activePage();
324 if (page != null) {
325 if (!page._handleExit({ validate: true })) {
326 // Page blocked wizard completion
327 return;
328 }
329 }
330 this.emit("close");
331};
332
RapidContext
Access · Discovery · Insight
www.rapidcontext.com