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}
19
20/**
21 * The base class for the HTML user interface widgets. The Widget
22 * class shouldn't be instantiated directly, instead one of the
23 * subclasses should be instantiated.
24 *
25 * @class
26 * @augments RapidContext.UI.Event
27 */
28RapidContext.Widget = function () {
29 throw new ReferenceError("cannot call Widget constructor");
30};
31
32/**
33 * The global widget registry. This is a widget lookup table where
34 * all widgets should have an entry. The entries should be added as
35 * the JavaScript file are loaded. Each widget is indexed by the
36 * widget name (class name) and point to the constructor function.
37 */
38RapidContext.Widget.Classes = {};
39
40/**
41 * Function to return unique identifiers.
42 *
43 * @return {number} the next number in the sequence
44 */
45RapidContext.Widget._nextId = MochiKit.Base.counter();
46
47/**
48 * Checks if the specified object is a widget. Any non-null object
49 * that looks like a DOM node and has the element class "widget"
50 * will cause this function to return `true`. Otherwise, `false` will
51 * be returned. As an option, this function can also check if the
52 * widget has a certain class by checking for an additional CSS
53 * class "widget<className>" (which is a standard followed by all
54 * widgets).
55 *
56 * @param {Object} obj the object to check
57 * @param {string} [className] the optional widget class name
58 *
59 * @return {boolean} `true` if the object looks like a widget, or
60 * `false` otherwise
61 */
62RapidContext.Widget.isWidget = function (obj, className) {
63 return obj &&
64 obj.nodeType > 0 &&
65 obj.classList.contains("widget") &&
66 (!className || obj.classList.contains("widget" + className));
67};
68
69/**
70 * Splits a string of CSS class names into an array.
71 *
72 * @param {Array|string} val the CSS class names
73 *
74 * @return {Array} nested arrays with single CSS class names
75 */
76RapidContext.Widget._toCssClass = function (val) {
77 if (Array.isArray(val)) {
78 // FIXME: Use Array.prototype.flatMap(...) here
79 return val.map(RapidContext.Widget._toCssClass);
80 } else if (val) {
81 return String(val).split(/\s+/g).filter(Boolean);
82 } else {
83 return [];
84 }
85};
86
87/**
88 * Adds all functions from a widget class to a DOM node. This will also convert
89 * the DOM node into a widget by adding the "widget" CSS class and all the
90 * default widget functions from `Widget.prototype` (if not already done).
91 *
92 * The default widget functions are added non-destructively, using the prefix
93 * "__" if also defined in the widget class.
94 *
95 * @param {Node} node the DOM node to modify
96 * @param {...(Object|function)} mixins the prototypes or classes to mixin
97 *
98 * @return {Widget} the widget DOM node
99 */
100RapidContext.Widget._widgetMixin = function (node, ...mixins) {
101 if (!RapidContext.Widget.isWidget(node)) {
102 node.classList.add("widget");
103 mixins.push(RapidContext.Widget);
104 mixins.push(RapidContext.UI.Event);
105 }
106 while (mixins.length > 0) {
107 let proto = mixins.pop();
108 if (typeof(proto) === "function") {
109 proto = proto.prototype;
110 }
111 for (let k of Object.getOwnPropertyNames(proto)) {
112 if (k !== "constructor") {
113 try {
114 if (k in node) {
115 node["__" + k] = node[k];
116 }
117 let desc = Object.getOwnPropertyDescriptor(proto, k);
118 Object.defineProperty(node, k, desc);
119 } catch (e) {
120 console.warn(`failed to set "${k}" in DOM node`, e, node);
121 }
122 }
123 }
124 }
125 return node;
126};
127
128/**
129 * Creates a new widget with the specified name, attributes and
130 * child widgets or DOM nodes. The widget class name must have been
131 * registered in the `RapidContext.Widget.Classes` lookup table, or an
132 * exception will be thrown. This function is identical to calling
133 * the constructor function directly.
134 *
135 * @param {string} name the widget class name
136 * @param {Object} attrs the widget and node attributes
137 * @param {...(Node|Widget)} [child] the child widgets or DOM nodes
138 *
139 * @return {Widget} the widget DOM node
140 *
141 * @throws {ReferenceError} if the widget class name couldn't be
142 * found in `RapidContext.Widget.Classes`
143 */
144RapidContext.Widget.createWidget = function (name, attrs/*, ...*/) {
145 let cls = RapidContext.Widget.Classes[name];
146 if (cls == null) {
147 throw new ReferenceError("failed to find widget '" + name +
148 "' in RapidContext.Widget.Classes");
149 }
150 return cls.apply(this, Array.from(arguments).slice(1));
151};
152
153/**
154 * Destroys a widget or a DOM node. This function will remove the DOM
155 * node from its parent, disconnect any signals and call destructor
156 * functions. It is also applied recursively to to all child nodes.
157 * Once destroyed, all references to the widget object should be
158 * cleared to reclaim browser memory.
159 *
160 * @param {Widget|Node|NodeList|Array} node the DOM node or list
161 */
162RapidContext.Widget.destroyWidget = function (node) {
163 if (node && node.nodeType === 1) {
164 if (typeof(node.destroy) == "function") {
165 node.destroy();
166 }
167 if (node.parentNode != null) {
168 node.remove();
169 }
170 MochiKit.Signal.disconnectAll(node);
171 MochiKit.Signal.disconnectAllTo(node);
172 RapidContext.UI.Event.off(node);
173 RapidContext.Widget.destroyWidget(node.childNodes);
174 } else if (node && typeof(node.length) === "number") {
175 Array.from(node).forEach(RapidContext.Widget.destroyWidget);
176 }
177};
178
179/**
180 * Returns the unique identifier for this DOM node. If a node id has
181 * already been set, that id will be returned. Otherwise a new id
182 * will be generated and assigned to the widget DOM node.
183 *
184 * @return {string} the the unique DOM node identifier
185 */
186RapidContext.Widget.prototype.uid = function () {
187 if (!this.id) {
188 this.id = "widget" + RapidContext.Widget._nextId();
189 }
190 return this.id;
191};
192
193/**
194 * The internal widget destructor function. This method should only
195 * be called by `destroyWidget()` and may be overridden by subclasses.
196 * By default this method does nothing.
197 */
198RapidContext.Widget.prototype.destroy = function () {
199 // Nothing to do by default
200};
201
202/**
203 * Returns the widget container DOM node. By default this method
204 * returns the widget itself, but subclasses may override it to place
205 * child DOM nodes in a different container.
206 *
207 * @return {Node} the container DOM node, or
208 * null if this widget has no container
209 */
210RapidContext.Widget.prototype._containerNode = function () {
211 return this;
212};
213
214/**
215 * Returns the widget style DOM node. By default this method returns
216 * the widget itself, but subclasses may override it to move widget
217 * styling (but not sizing or positioning) to a subnode.
218 *
219 * @return {Node} the style DOM node
220 */
221RapidContext.Widget.prototype._styleNode = function () {
222 return this;
223};
224
225/**
226 * Updates the widget or HTML DOM node attributes. This method is
227 * sometimes overridden by individual widgets to allow modification
228 * of additional widget attributes.
229 *
230 * @param {Object} attrs the widget and node attributes to set
231 * @param {boolean} [attrs.disabled] the disabled widget flag
232 * @param {boolean} [attrs.hidden] the hidden widget flag
233 * @param {string} [attrs.class] the CSS class names
234 */
235RapidContext.Widget.prototype.setAttrs = function (attrs) {
236 /* eslint max-depth: "off" */
237 for (let name in attrs) {
238 let value = attrs[name];
239 if (name == "disabled") {
240 this._setDisabled(value);
241 } else if (name == "hidden") {
242 this._setHidden(value);
243 } else if (name == "class") {
244 let elem = this._styleNode();
245 this.removeClass.apply(this, elem.className.split(/\s+/));
246 this.addClass.apply(this, value.split(/\s+/));
247 } else if (name == "style") {
248 if (typeof(value) == "string") {
249 let func = (res, part) => {
250 let a = part.split(":");
251 let k = a[0].trim();
252 if (k && a.length > 1) {
253 res[k] = a.slice(1).join(":").trim();
254 }
255 return res;
256 };
257 value = value.split(";").reduce(func, {});
258 }
259 this.setStyle(value);
260 } else {
261 let isString = typeof(value) == "string";
262 let isBoolean = typeof(value) == "boolean";
263 let isNumber = typeof(value) == "number";
264 if (isString || isBoolean || isNumber) {
265 this.setAttribute(name, value);
266 } else {
267 this.removeAttribute(name);
268 }
269 if (value != null) {
270 this[name] = value;
271 } else {
272 delete this[name];
273 }
274 }
275 }
276};
277
278/**
279 * Updates the CSS styles of this HTML DOM node. This method is
280 * identical to `MochiKit.Style.setStyle`, but uses "this" as the
281 * first argument.
282 *
283 * @param {Object} styles an object with the styles to set
284 *
285 * @example
286 * widget.setStyle({ "font-size": "bold", "color": "red" });
287 */
288RapidContext.Widget.prototype.setStyle = function (styles) {
289 let copyStyle = (o, k) => (o[k] = styles[k], o);
290 let thisProps = [
291 "width", "height", "zIndex", "z-index",
292 "position", "top", "bottom", "left", "right"
293 ].filter((k) => k in styles);
294 let thisStyles = thisProps.reduce(copyStyle, {});
295 let otherProps = Object.keys(styles).filter((k) => !thisProps.includes(k));
296 let otherStyles = otherProps.reduce(copyStyle, {});
297 MochiKit.Style.setStyle(this, thisStyles);
298 MochiKit.Style.setStyle(this._styleNode(), otherStyles);
299};
300
301/**
302 * Checks if this HTML DOM node has the specified CSS class names.
303 * Note that more than one CSS class name may be checked, in which
304 * case all must be present.
305 *
306 * @param {...(string|Array)} cls the CSS class names to check
307 *
308 * @return {boolean} `true` if all CSS classes were present, or
309 * `false` otherwise
310 */
311RapidContext.Widget.prototype.hasClass = function (/* ... */) {
312 function isMatch(val) {
313 if (Array.isArray(val)) {
314 return val.every(isMatch);
315 } else {
316 return elem.classList.contains(val);
317 }
318 }
319 let elem = this._styleNode();
320 // FIXME: Use Array.prototype.flatMap(...) here
321 return Array.from(arguments).map(RapidContext.Widget._toCssClass).every(isMatch);
322};
323
324/**
325 * Adds the specified CSS class names to this HTML DOM node.
326 *
327 * @param {...(string|Array)} cls the CSS class names to add
328 */
329RapidContext.Widget.prototype.addClass = function (/* ... */) {
330 function add(val) {
331 if (Array.isArray(val)) {
332 val.forEach(add);
333 } else {
334 elem.classList.add(val);
335 }
336 }
337 let elem = this._styleNode();
338 // FIXME: Use Array.prototype.flatMap(...) here
339 Array.from(arguments).map(RapidContext.Widget._toCssClass).forEach(add);
340};
341
342/**
343 * Removes the specified CSS class names from this HTML DOM node.
344 * Note that this method will not remove any class starting with
345 * "widget".
346 *
347 * @param {...(string|Array)} cls the CSS class names to remove
348 */
349RapidContext.Widget.prototype.removeClass = function (/* ... */) {
350 function remove(val) {
351 if (Array.isArray(val)) {
352 val.filter(Boolean).forEach(remove);
353 } else if (!val.startsWith("widget")) {
354 elem.classList.remove(val);
355 }
356 }
357 let elem = this._styleNode();
358 // FIXME: Use Array.prototype.flatMap(...) here
359 Array.from(arguments).map(RapidContext.Widget._toCssClass).forEach(remove);
360};
361
362/**
363 * Toggles adding and removing the specified CSS class names to and
364 * from this HTML DOM node. If all the CSS classes are already set,
365 * they will be removed. Otherwise they will be added.
366 *
367 * @param {...(string|Array)} cls the CSS class names to remove
368 *
369 * @return {boolean} `true` if the CSS classes were added, or
370 * `false` otherwise
371 */
372RapidContext.Widget.prototype.toggleClass = function (/* ... */) {
373 if (this.hasClass.apply(this, arguments)) {
374 this.removeClass.apply(this, arguments);
375 return false;
376 } else {
377 this.addClass.apply(this, arguments);
378 return true;
379 }
380};
381
382/**
383 * Checks if this widget is disabled. This method checks both the
384 * "widgetDisabled" CSS class and the `disabled` property. Changes
385 * to the disabled status can be made with `enable()`, `disable()` or
386 * `setAttrs()`.
387 *
388 * @return {boolean} `true` if the widget is disabled, or
389 * `false` otherwise
390 */
391RapidContext.Widget.prototype.isDisabled = function () {
392 return this.disabled === true && this.classList.contains("widgetDisabled");
393};
394
395/**
396 * Performs the changes corresponding to setting the `disabled`
397 * widget attribute.
398 *
399 * @param {boolean} value the new attribute value
400 */
401RapidContext.Widget.prototype._setDisabled = function (value) {
402 value = RapidContext.Data.bool(value);
403 this.classList.toggle("widgetDisabled", value);
404 this.setAttribute("disabled", value);
405 this.disabled = value;
406};
407
408/**
409 * Enables this widget if it was previously disabled. This is
410 * equivalent to calling `setAttrs({ disabled: false })`.
411 */
412RapidContext.Widget.prototype.enable = function () {
413 this.setAttrs({ disabled: false });
414};
415
416/**
417 * Disables this widget if it was previously enabled. This method is
418 * equivalent to calling `setAttrs({ disabled: true })`.
419 */
420RapidContext.Widget.prototype.disable = function () {
421 this.setAttrs({ disabled: true });
422};
423
424/**
425 * Checks if this widget node is hidden. This method checks for the
426 * existence of the `widgetHidden` CSS class. It does NOT check the
427 * actual widget visibility (the `display` style property set by
428 * animations for example).
429 *
430 * @return {boolean} `true` if the widget is hidden, or
431 * `false` otherwise
432 */
433RapidContext.Widget.prototype.isHidden = function () {
434 return this.classList.contains("widgetHidden");
435};
436
437/**
438 * Performs the changes corresponding to setting the `hidden`
439 * widget attribute.
440 *
441 * @param {boolean} value the new attribute value
442 */
443RapidContext.Widget.prototype._setHidden = function (value) {
444 value = RapidContext.Data.bool(value);
445 this.classList.toggle("widgetHidden", value);
446 this.setAttribute("hidden", value);
447 this.hidden = value;
448};
449
450/**
451 * Shows this widget node if it was previously hidden. This method is
452 * equivalent to calling `setAttrs({ hidden: false })`. It is safe
453 * for all types of widgets, since it only removes the `widgetHidden`
454 * CSS class instead of setting the `display` style property.
455 */
456RapidContext.Widget.prototype.show = function () {
457 this.setAttrs({ hidden: false });
458};
459
460/**
461 * Hides this widget node if it was previously visible. This method
462 * is equivalent to calling `setAttrs({ hidden: true })`. It is safe
463 * for all types of widgets, since it only adds the `widgetHidden`
464 * CSS class instead of setting the `display` style property.
465 */
466RapidContext.Widget.prototype.hide = function () {
467 this.setAttrs({ hidden: true });
468};
469
470/**
471 * Blurs (unfocuses) this DOM node and all relevant child nodes. This function
472 * will recursively blur all `<a>`, `<button>`, `<input>`, `<textarea>` and
473 * `<select>` child nodes found.
474 */
475RapidContext.Widget.prototype.blurAll = function () {
476 RapidContext.Util.blurAll(this);
477};
478
479/**
480 * Returns an array with all child DOM nodes. Note that the array is
481 * a real JavaScript array, not a dynamic `NodeList`. This method is
482 * sometimes overridden by child widgets in order to hide
483 * intermediate DOM nodes required by the widget.
484 *
485 * @return {Array} the array of child DOM nodes
486 */
487RapidContext.Widget.prototype.getChildNodes = function () {
488 let elem = this._containerNode();
489 return elem ? Array.from(elem.childNodes) : [];
490};
491
492/**
493 * Adds a single child DOM node to this widget. This method is
494 * sometimes overridden by child widgets in order to hide or control
495 * intermediate DOM nodes required by the widget.
496 *
497 * @param {Widget|Node} child the DOM node to add
498 */
499RapidContext.Widget.prototype.addChildNode = function (child) {
500 let elem = this._containerNode();
501 if (elem) {
502 elem.append(child);
503 } else {
504 throw new Error("cannot add child node, widget is not a container");
505 }
506};
507
508/**
509 * Removes a single child DOM node from this widget. This method is
510 * sometimes overridden by child widgets in order to hide or control
511 * intermediate DOM nodes required by the widget.
512 *
513 * Note that this method will NOT destroy the removed child widget,
514 * so care must be taken to ensure proper child widget destruction.
515 *
516 * @param {Widget|Node} child the DOM node to remove
517 */
518RapidContext.Widget.prototype.removeChildNode = function (child) {
519 let elem = this._containerNode();
520 if (elem) {
521 elem.removeChild(child);
522 }
523};
524
525/**
526 * Adds one or more children to this widget. This method will flatten any
527 * arrays among the arguments and ignores any `null` or `undefined` arguments.
528 * Any DOM nodes or widgets will be added to the end, and other objects will be
529 * converted to a text node first. Subclasses should normally override the
530 * `addChildNode()` method instead of this one, since that is the basis for
531 * DOM node insertion.
532 *
533 * @param {...(string|Node|Array)} child the children to add
534 */
535RapidContext.Widget.prototype.addAll = function (...children) {
536 [].concat(...children).filter((o) => o != null).forEach((child) => {
537 this.addChildNode(child);
538 });
539};
540
541/**
542 * Removes all children to this widget. This method will also destroy and child
543 * widgets and disconnect all signal listeners. This method uses the
544 * `getChildNodes()` and `removeChildNode()` methods to find and remove the
545 * individual child nodes.
546 */
547RapidContext.Widget.prototype.removeAll = function () {
548 let children = this.getChildNodes();
549 for (let i = children.length - 1; i >= 0; i--) {
550 this.removeChildNode(children[i]);
551 RapidContext.Widget.destroyWidget(children[i]);
552 }
553};
554
RapidContext
Access · Discovery · Insight
www.rapidcontext.com