Source RapidContext_Widget_Popup.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}
19RapidContext.Widget = RapidContext.Widget || { Classes: {} };
20
21/**
22 * Creates a new popup widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {number} [attrs.delay] the widget auto-hide delay in
27 *            milliseconds, defaults to `5000`
28 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to `true`
29 * @param {...(Node|Array)} [child] the child widgets or DOM nodes
30 *
31 * @return {Widget} the widget DOM node
32 *
33 * @class The popup widget class. Used to provide a popup menu or information
34 *     area, using a `<div>` HTML element. The `Popup` widget will automatically
35 *     disappear after a configurable amount of time, unless the user performs
36 *     keyboard or mouse actions related to the popup.
37 * @extends RapidContext.Widget
38 *
39 * @example <caption>JavaScript</caption>
40 * let hr = document.createElement("hr");
41 * let third = RapidContext.UI.LI({ "class": "disabled" }, "Third");
42 * let popup = RapidContext.Widget.Popup({}, "First",  "Second", hr, third);
43 *
44 * @example <caption>User Interface XML</caption>
45 * <Popup id="examplePopup">
46 *   <li>&#187; First Item</div>
47 *   <li>&#187; Second Item</div>
48 *   <hr />
49 *   <li class="disabled">&#187; Third Item</div>
50 * </Popup>
51 */
52RapidContext.Widget.Popup = function (attrs/*, ...*/) {
53    const o = document.createElement("menu");
54    RapidContext.Widget._widgetMixin(o, RapidContext.Widget.Popup);
55    o.addClass("widgetPopup");
56    o._setHidden(true);
57    o.tabIndex = -1;
58    o.selectedIndex = -1;
59    o._delayTimer = null;
60    o.setAttrs({ delay: 5000, ...attrs });
61    o.addAll(Array.from(arguments).slice(1));
62    o.on("click mousemove", ".widgetPopup > *", o._handleMouseEvent);
63    o.on("keydown", o._handleKeyDown);
64    return o;
65};
66
67//Register widget class
68RapidContext.Widget.Classes.Popup = RapidContext.Widget.Popup;
69
70/**
71 * Emitted when the popup is shown.
72 *
73 * @name RapidContext.Widget.Popup#onshow
74 * @event
75 */
76
77/**
78 * Emitted when the popup is hidden.
79 *
80 * @name RapidContext.Widget.Popup#onhide
81 * @event
82 */
83
84/**
85 * Emitted when a menu item is selected.
86 *
87 * @name RapidContext.Widget.Popup#menuselect
88 * @event
89 */
90
91/**
92 * Updates the widget or HTML DOM node attributes.
93 *
94 * @param {Object} attrs the widget and node attributes to set
95 * @param {number} [attrs.delay] the widget auto-hide delay in
96 *            milliseconds, defaults to 5000
97 * @param {boolean} [attrs.hidden] the hidden widget flag
98 */
99RapidContext.Widget.Popup.prototype.setAttrs = function (attrs) {
100    attrs = { ...attrs };
101    if ("delay" in attrs) {
102        attrs.delay = parseInt(attrs.delay, 10) || 5000;
103        this.resetDelay();
104    }
105    if ("showAnim" in attrs) {
106        console.warn("deprecated: popup 'showAnim' attribute is ignored");
107        delete attrs.showAnim;
108    }
109    if ("hideAnim" in attrs) {
110        console.warn("deprecated: popup 'hideAnim' attribute is ignored");
111        delete attrs.hideAnim;
112    }
113    if ("hidden" in attrs) {
114        this._setHiddenPopup(RapidContext.Data.bool(attrs.hidden));
115        delete attrs.hidden;
116    }
117    this.__setAttrs(attrs);
118};
119
120/**
121 * Adds a single child node to this widget.
122 *
123 * @param {string|Node|Widget} child the item to add
124 */
125RapidContext.Widget.Popup.prototype.addChildNode = function (child) {
126    if (!child.nodeType) {
127        child = RapidContext.UI.LI({}, child);
128    }
129    this._containerNode(true).append(child);
130};
131
132/**
133 * Performs the changes corresponding to setting the `hidden`
134 * widget attribute for the `Popup` widget.
135 *
136 * @param {boolean} value the new attribute value
137 */
138RapidContext.Widget.Popup.prototype._setHiddenPopup = function (value) {
139    if (value && !this.isHidden()) {
140        this._setHidden(true);
141        this.style.maxHeight = 0;
142        this.emit("hide");
143        setTimeout(() => this.blur(), 100);
144    } else if (!value && this.isHidden()) {
145        this.selectChild(-1);
146        this._setHidden(false);
147        this.style.maxHeight = `${this.scrollHeight + 10}px`;
148        this.scrollTop = 0;
149        this.emit("show");
150        setTimeout(() => this.focus(), 100);
151    }
152    this.resetDelay();
153};
154
155/**
156 * Resets the popup auto-hide timer. Might be called manually when
157 * receiving events on other widgets related to this one.
158 */
159RapidContext.Widget.Popup.prototype.resetDelay = function () {
160    if (this._delayTimer) {
161        clearTimeout(this._delayTimer);
162        this._delayTimer = null;
163    }
164    if (!this.isHidden() && this.delay > 0) {
165        this._delayTimer = setTimeout(() => this.hide(), this.delay);
166    }
167};
168
169/**
170 * Returns the currently selected child node.
171 *
172 * @return {Node} the currently selected child node, or
173 *         null if no node is selected
174 */
175RapidContext.Widget.Popup.prototype.selectedChild = function () {
176    return this.childNodes[this.selectedIndex] || null;
177};
178
179/**
180 * Marks a popup child as selected. The currently selected child will
181 * automatically be unselected by this method.
182 *
183 * @param {number|Node} indexOrNode the child node index or DOM node,
184 *            use a negative value to unselect
185 *
186 * @return the index of the newly selected child, or
187 *         -1 if none was selected
188 */
189RapidContext.Widget.Popup.prototype.selectChild = function (indexOrNode) {
190    let node = this.selectedChild();
191    if (node != null) {
192        node.classList.remove("selected");
193    }
194    const isNumber = typeof(indexOrNode) == "number";
195    const index = isNumber ? indexOrNode : Array.from(this.childNodes).indexOf(indexOrNode);
196    node = this.childNodes[index];
197    const selector = "li:not(.disabled), .widgetPopupItem:not(.disabled)";
198    if (index >= 0 && node && node.matches(selector)) {
199        this.selectedIndex = index;
200        node.classList.add("selected");
201        const top = node.offsetTop;
202        const bottom = top + node.offsetHeight + 5;
203        if (this.scrollTop + this.clientHeight < bottom) {
204            this.scrollTop = bottom - this.clientHeight;
205        }
206        if (this.scrollTop > top) {
207            this.scrollTop = top;
208        }
209    } else {
210        this.selectedIndex = -1;
211    }
212    return this.selectedIndex;
213};
214
215/**
216 * Moves the current selection by a numeric offset.
217 *
218 * @param {number} offset the selection offset (a positive or
219 *            negative number)
220 *
221 * @return the index of the newly selected child, or
222 *         -1 if none was selected
223 */
224RapidContext.Widget.Popup.prototype.selectMove = function (offset) {
225    const active = this.selectedChild();
226    const items = this.querySelectorAll("li:not(.disabled), .widgetPopupItem:not(.disabled)");
227    let index;
228    if (active) {
229        index = Array.from(items).indexOf(active) + offset;
230    } else {
231        index = (offset < 0) ? items.length - 1 : 0;
232    }
233    index += (index < 0) ? items.length : 0;
234    index -= (index >= items.length) ? items.length : 0;
235    return this.selectChild(items[index]);
236};
237
238/**
239 * Handles mouse events on the popup.
240 *
241 * @param {Event} evt the DOM Event object
242 */
243RapidContext.Widget.Popup.prototype._handleMouseEvent = function (evt) {
244    this.resetDelay();
245    const node = evt.delegateTarget;
246    if (this.selectChild(node) >= 0 && evt.type == "click") {
247        const detail = { menu: this, item: node };
248        this.emit("menuselect", { detail });
249    }
250};
251
252/**
253 * Handles the key down event on the popup.
254 *
255 * @param {Event} evt the DOM Event object
256 */
257RapidContext.Widget.Popup.prototype._handleKeyDown = function (evt) {
258    this.resetDelay();
259    switch (evt.key) {
260    case "ArrowUp":
261    case "ArrowDown":
262        evt.preventDefault();
263        evt.stopImmediatePropagation();
264        this.selectMove(evt.key == "ArrowUp" ? -1 : 1);
265        break;
266    case "Escape":
267        evt.preventDefault();
268        evt.stopImmediatePropagation();
269        this.hide();
270        break;
271    case "Tab":
272    case "Enter":
273        evt.preventDefault();
274        evt.stopImmediatePropagation();
275        if (this.selectedChild()) {
276            const detail = { menu: this, item: this.selectedChild() };
277            this.emit("menuselect", { detail });
278        }
279        break;
280    }
281};
282