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>» First Item</div>
47 * <li>» Second Item</div>
48 * <hr />
49 * <li class="disabled">» 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
RapidContext
Access · Discovery · Insight
www.rapidcontext.com