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
RapidContext
Access · Discovery · Insight
www.rapidcontext.com