Source RapidContext_Widget.js

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