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}
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 * Dispatches a custom event from this DOM node. The event will be
227 * created and emitted asynchronously (via setTimeout).
228 *
229 * @param {string} type the event type (e.g. `validate`)
230 * @param {Object} [opts] the event options (e.g. `{ bubbles: true }`)
231 *
232 * @deprecated Use `emit(type, opts)` instead.
233 */
234RapidContext.Widget.prototype._dispatch = function (type, opts) {
235 console.warn("deprecated: use 'emit' method instead of '_dispatch'");
236 this.emit(type, opts);
237};
238
239/**
240 * Updates the widget or HTML DOM node attributes. This method is
241 * sometimes overridden by individual widgets to allow modification
242 * of additional widget attributes.
243 *
244 * @param {Object} attrs the widget and node attributes to set
245 * @param {boolean} [attrs.disabled] the disabled widget flag
246 * @param {boolean} [attrs.hidden] the hidden widget flag
247 * @param {string} [attrs.class] the CSS class names
248 */
249RapidContext.Widget.prototype.setAttrs = function (attrs) {
250 /* eslint max-depth: "off" */
251 for (let name in attrs) {
252 let value = attrs[name];
253 if (name == "disabled") {
254 this._setDisabled(value);
255 } else if (name == "hidden") {
256 this._setHidden(value);
257 } else if (name == "class") {
258 let elem = this._styleNode();
259 this.removeClass.apply(this, elem.className.split(/\s+/));
260 this.addClass.apply(this, value.split(/\s+/));
261 } else if (name == "style") {
262 if (typeof(value) == "string") {
263 let func = (res, part) => {
264 let a = part.split(":");
265 let k = a[0].trim();
266 if (k && a.length > 1) {
267 res[k] = a.slice(1).join(":").trim();
268 }
269 return res;
270 };
271 value = value.split(";").reduce(func, {});
272 }
273 this.setStyle(value);
274 } else {
275 let isString = typeof(value) == "string";
276 let isBoolean = typeof(value) == "boolean";
277 let isNumber = typeof(value) == "number";
278 if (isString || isBoolean || isNumber) {
279 this.setAttribute(name, value);
280 } else {
281 this.removeAttribute(name);
282 }
283 if (value != null) {
284 this[name] = value;
285 } else {
286 delete this[name];
287 }
288 }
289 }
290};
291
292/**
293 * Updates the CSS styles of this HTML DOM node. This method is
294 * identical to `MochiKit.Style.setStyle`, but uses "this" as the
295 * first argument.
296 *
297 * @param {Object} styles an object with the styles to set
298 *
299 * @example
300 * widget.setStyle({ "font-size": "bold", "color": "red" });
301 */
302RapidContext.Widget.prototype.setStyle = function (styles) {
303 let copyStyle = (o, k) => (o[k] = styles[k], o);
304 let thisProps = [
305 "width", "height", "zIndex", "z-index",
306 "position", "top", "bottom", "left", "right"
307 ].filter((k) => k in styles);
308 let thisStyles = thisProps.reduce(copyStyle, {});
309 let otherProps = Object.keys(styles).filter((k) => !thisProps.includes(k));
310 let otherStyles = otherProps.reduce(copyStyle, {});
311 MochiKit.Style.setStyle(this, thisStyles);
312 MochiKit.Style.setStyle(this._styleNode(), otherStyles);
313};
314
315/**
316 * Checks if this HTML DOM node has the specified CSS class names.
317 * Note that more than one CSS class name may be checked, in which
318 * case all must be present.
319 *
320 * @param {...(string|Array)} cls the CSS class names to check
321 *
322 * @return {boolean} `true` if all CSS classes were present, or
323 * `false` otherwise
324 */
325RapidContext.Widget.prototype.hasClass = function (/* ... */) {
326 function isMatch(val) {
327 if (Array.isArray(val)) {
328 return val.every(isMatch);
329 } else {
330 return elem.classList.contains(val);
331 }
332 }
333 let elem = this._styleNode();
334 // FIXME: Use Array.prototype.flatMap(...) here
335 return Array.from(arguments).map(RapidContext.Widget._toCssClass).every(isMatch);
336};
337
338/**
339 * Adds the specified CSS class names to this HTML DOM node.
340 *
341 * @param {...(string|Array)} cls the CSS class names to add
342 */
343RapidContext.Widget.prototype.addClass = function (/* ... */) {
344 function add(val) {
345 if (Array.isArray(val)) {
346 val.forEach(add);
347 } else {
348 elem.classList.add(val);
349 }
350 }
351 let elem = this._styleNode();
352 // FIXME: Use Array.prototype.flatMap(...) here
353 Array.from(arguments).map(RapidContext.Widget._toCssClass).forEach(add);
354};
355
356/**
357 * Removes the specified CSS class names from this HTML DOM node.
358 * Note that this method will not remove any class starting with
359 * "widget".
360 *
361 * @param {...(string|Array)} cls the CSS class names to remove
362 */
363RapidContext.Widget.prototype.removeClass = function (/* ... */) {
364 function remove(val) {
365 if (Array.isArray(val)) {
366 val.filter(Boolean).forEach(remove);
367 } else if (!val.startsWith("widget")) {
368 elem.classList.remove(val);
369 }
370 }
371 let elem = this._styleNode();
372 // FIXME: Use Array.prototype.flatMap(...) here
373 Array.from(arguments).map(RapidContext.Widget._toCssClass).forEach(remove);
374};
375
376/**
377 * Toggles adding and removing the specified CSS class names to and
378 * from this HTML DOM node. If all the CSS classes are already set,
379 * they will be removed. Otherwise they will be added.
380 *
381 * @param {...(string|Array)} cls the CSS class names to remove
382 *
383 * @return {boolean} `true` if the CSS classes were added, or
384 * `false` otherwise
385 */
386RapidContext.Widget.prototype.toggleClass = function (/* ... */) {
387 if (this.hasClass.apply(this, arguments)) {
388 this.removeClass.apply(this, arguments);
389 return false;
390 } else {
391 this.addClass.apply(this, arguments);
392 return true;
393 }
394};
395
396/**
397 * Checks if this widget is disabled. This method checks both the
398 * "widgetDisabled" CSS class and the `disabled` property. Changes
399 * to the disabled status can be made with `enable()`, `disable()` or
400 * `setAttrs()`.
401 *
402 * @return {boolean} `true` if the widget is disabled, or
403 * `false` otherwise
404 */
405RapidContext.Widget.prototype.isDisabled = function () {
406 return this.disabled === true && this.classList.contains("widgetDisabled");
407};
408
409/**
410 * Performs the changes corresponding to setting the `disabled`
411 * widget attribute.
412 *
413 * @param {boolean} value the new attribute value
414 */
415RapidContext.Widget.prototype._setDisabled = function (value) {
416 value = RapidContext.Data.bool(value);
417 this.classList.toggle("widgetDisabled", value);
418 this.setAttribute("disabled", value);
419 this.disabled = value;
420};
421
422/**
423 * Enables this widget if it was previously disabled. This is
424 * equivalent to calling `setAttrs({ disabled: false })`.
425 */
426RapidContext.Widget.prototype.enable = function () {
427 this.setAttrs({ disabled: false });
428};
429
430/**
431 * Disables this widget if it was previously enabled. This method is
432 * equivalent to calling `setAttrs({ disabled: true })`.
433 */
434RapidContext.Widget.prototype.disable = function () {
435 this.setAttrs({ disabled: true });
436};
437
438/**
439 * Checks if this widget node is hidden. This method checks for the
440 * existence of the `widgetHidden` CSS class. It does NOT check the
441 * actual widget visibility (the `display` style property set by
442 * animations for example).
443 *
444 * @return {boolean} `true` if the widget is hidden, or
445 * `false` otherwise
446 */
447RapidContext.Widget.prototype.isHidden = function () {
448 return this.classList.contains("widgetHidden");
449};
450
451/**
452 * Performs the changes corresponding to setting the `hidden`
453 * widget attribute.
454 *
455 * @param {boolean} value the new attribute value
456 */
457RapidContext.Widget.prototype._setHidden = function (value) {
458 value = RapidContext.Data.bool(value);
459 this.classList.toggle("widgetHidden", value);
460 this.setAttribute("hidden", value);
461 this.hidden = value;
462};
463
464/**
465 * Shows this widget node if it was previously hidden. This method is
466 * equivalent to calling `setAttrs({ hidden: false })`. It is safe
467 * for all types of widgets, since it only removes the `widgetHidden`
468 * CSS class instead of setting the `display` style property.
469 */
470RapidContext.Widget.prototype.show = function () {
471 this.setAttrs({ hidden: false });
472};
473
474/**
475 * Hides this widget node if it was previously visible. This method
476 * is equivalent to calling `setAttrs({ hidden: true })`. It is safe
477 * for all types of widgets, since it only adds the `widgetHidden`
478 * CSS class instead of setting the `display` style property.
479 */
480RapidContext.Widget.prototype.hide = function () {
481 this.setAttrs({ hidden: true });
482};
483
484/**
485 * Performs a visual effect animation on this widget. This is
486 * implemented using the `MochiKit.Visual` effect package. All options
487 * sent to this function will be passed on to the appropriate
488 * `MochiKit.Visual` function.
489 *
490 * @param {Object} opts the visual effect options
491 * @param {string} opts.effect the MochiKit.Visual effect name
492 * @param {string} opts.queue the MochiKit.Visual queue handling,
493 * defaults to "replace" and a unique scope for each widget
494 * (see `MochiKit.Visual` for full options)
495 *
496 * @example
497 * widget.animate({ effect: "fade", duration: 0.5 });
498 * widget.animate({ effect: "Move", transition: "spring", y: 300 });
499 *
500 * @deprecated Use CSS animations instead.
501 */
502RapidContext.Widget.prototype.animate = function (opts) {
503 console.warn("deprecated: animate() method called, use CSS animations instead");
504 let queue = { scope: this.uid(), position: "replace" };
505 opts = MochiKit.Base.updatetree({ queue: queue }, opts);
506 if (typeof(opts.queue) == "string") {
507 queue.position = opts.queue;
508 opts.queue = queue;
509 }
510 let func = MochiKit.Visual[opts.effect];
511 if (typeof(func) == "function") {
512 func.call(null, this, opts);
513 }
514};
515
516/**
517 * Blurs (unfocuses) this DOM node and all relevant child nodes. This function
518 * will recursively blur all `<a>`, `<button>`, `<input>`, `<textarea>` and
519 * `<select>` child nodes found.
520 */
521RapidContext.Widget.prototype.blurAll = function () {
522 RapidContext.Util.blurAll(this);
523};
524
525/**
526 * Returns an array with all child DOM nodes. Note that the array is
527 * a real JavaScript array, not a dynamic `NodeList`. This method is
528 * sometimes overridden by child widgets in order to hide
529 * intermediate DOM nodes required by the widget.
530 *
531 * @return {Array} the array of child DOM nodes
532 */
533RapidContext.Widget.prototype.getChildNodes = function () {
534 let elem = this._containerNode();
535 return elem ? Array.from(elem.childNodes) : [];
536};
537
538/**
539 * Adds a single child DOM node to this widget. This method is
540 * sometimes overridden by child widgets in order to hide or control
541 * intermediate DOM nodes required by the widget.
542 *
543 * @param {Widget|Node} child the DOM node to add
544 */
545RapidContext.Widget.prototype.addChildNode = function (child) {
546 let elem = this._containerNode();
547 if (elem) {
548 elem.append(child);
549 } else {
550 throw new Error("cannot add child node, widget is not a container");
551 }
552};
553
554/**
555 * Removes a single child DOM node from this widget. This method is
556 * sometimes overridden by child widgets in order to hide or control
557 * intermediate DOM nodes required by the widget.
558 *
559 * Note that this method will NOT destroy the removed child widget,
560 * so care must be taken to ensure proper child widget destruction.
561 *
562 * @param {Widget|Node} child the DOM node to remove
563 */
564RapidContext.Widget.prototype.removeChildNode = function (child) {
565 let elem = this._containerNode();
566 if (elem) {
567 elem.removeChild(child);
568 }
569};
570
571/**
572 * Adds one or more children to this widget. This method will flatten any
573 * arrays among the arguments and ignores any `null` or `undefined` arguments.
574 * Any DOM nodes or widgets will be added to the end, and other objects will be
575 * converted to a text node first. Subclasses should normally override the
576 * `addChildNode()` method instead of this one, since that is the basis for
577 * DOM node insertion.
578 *
579 * @param {...(string|Node|Array)} child the children to add
580 */
581RapidContext.Widget.prototype.addAll = function (...children) {
582 [].concat(...children).filter((o) => o != null).forEach((child) => {
583 this.addChildNode(child);
584 });
585};
586
587/**
588 * Removes all children to this widget. This method will also destroy and child
589 * widgets and disconnect all signal listeners. This method uses the
590 * `getChildNodes()` and `removeChildNode()` methods to find and remove the
591 * individual child nodes.
592 */
593RapidContext.Widget.prototype.removeAll = function () {
594 let children = this.getChildNodes();
595 for (let i = children.length - 1; i >= 0; i--) {
596 this.removeChildNode(children[i]);
597 RapidContext.Widget.destroyWidget(children[i]);
598 }
599};
600
RapidContext
Access · Discovery · Insight
www.rapidcontext.com