Source RapidContext_Widget.js

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