1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2024 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 let 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(Object.assign({ 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 = Object.assign({}, 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 index;
191 let node = this.selectedChild();
192 if (node != null) {
193 node.classList.remove("selected");
194 }
195 let isNumber = typeof(indexOrNode) == "number";
196 index = isNumber ? indexOrNode : Array.from(this.childNodes).indexOf(indexOrNode);
197 node = this.childNodes[index];
198 let selector = "li:not(.disabled), .widgetPopupItem:not(.disabled)";
199 if (index >= 0 && node && node.matches(selector)) {
200 this.selectedIndex = index;
201 node.classList.add("selected");
202 let top = node.offsetTop;
203 let bottom = top + node.offsetHeight + 5;
204 if (this.scrollTop + this.clientHeight < bottom) {
205 this.scrollTop = bottom - this.clientHeight;
206 }
207 if (this.scrollTop > top) {
208 this.scrollTop = top;
209 }
210 } else {
211 this.selectedIndex = -1;
212 }
213 return this.selectedIndex;
214};
215
216/**
217 * Moves the current selection by a numeric offset.
218 *
219 * @param {number} offset the selection offset (a positive or
220 * negative number)
221 *
222 * @return the index of the newly selected child, or
223 * -1 if none was selected
224 */
225RapidContext.Widget.Popup.prototype.selectMove = function (offset) {
226 let active = this.selectedChild();
227 let items = this.querySelectorAll("li:not(.disabled), .widgetPopupItem:not(.disabled)");
228 let index;
229 if (active) {
230 index = Array.from(items).indexOf(active) + offset;
231 } else {
232 index = (offset < 0) ? items.length - 1 : 0;
233 }
234 index += (index < 0) ? items.length : 0;
235 index -= (index >= items.length) ? items.length : 0;
236 return this.selectChild(items[index]);
237};
238
239/**
240 * Handles mouse events on the popup.
241 *
242 * @param {Event} evt the DOM Event object
243 */
244RapidContext.Widget.Popup.prototype._handleMouseEvent = function (evt) {
245 this.resetDelay();
246 let node = evt.delegateTarget;
247 if (this.selectChild(node) >= 0 && evt.type == "click") {
248 let detail = { menu: this, item: node };
249 this.emit("menuselect", { detail });
250 }
251};
252
253/**
254 * Handles the key down event on the popup.
255 *
256 * @param {Event} evt the DOM Event object
257 */
258RapidContext.Widget.Popup.prototype._handleKeyDown = function (evt) {
259 this.resetDelay();
260 switch (evt.key) {
261 case "ArrowUp":
262 case "ArrowDown":
263 evt.preventDefault();
264 evt.stopImmediatePropagation();
265 this.selectMove(evt.key == "ArrowUp" ? -1 : 1);
266 break;
267 case "Escape":
268 evt.preventDefault();
269 evt.stopImmediatePropagation();
270 this.hide();
271 break;
272 case "Tab":
273 case "Enter":
274 evt.preventDefault();
275 evt.stopImmediatePropagation();
276 if (this.selectedChild()) {
277 let detail = { menu: this, item: this.selectedChild() };
278 this.emit("menuselect", { detail });
279 }
280 break;
281 }
282};
283
RapidContext
Access · Discovery · Insight
www.rapidcontext.com