Source RapidContext_Widget_Popup.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2023 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 = MochiKit.DOM.HR();
41 * let third = MochiKit.DOM.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    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 = MochiKit.DOM.LI(null, 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 = (offset < 0) ? offset : Math.max(0, offset - 1);
229    if (active) {
230        index = Array.from(items).indexOf(active) + offset;
231    }
232    index += (index < 0) ? items.length : 0;
233    index -= (index >= items.length) ? items.length - 1 : 0;
234    return this.selectChild(items[index]);
235};
236
237/**
238 * Handles mouse events on the popup.
239 *
240 * @param {Event} evt the DOM Event object
241 */
242RapidContext.Widget.Popup.prototype._handleMouseEvent = function (evt) {
243    this.show();
244    let node = evt.delegateTarget;
245    if (this.selectChild(node) >= 0 && evt.type == "click") {
246        let detail = { menu: this, item: node };
247        this.emit("menuselect", { detail });
248    }
249};
250
251/**
252 * Handles the key down event on the popup.
253 *
254 * @param {Event} evt the DOM Event object
255 */
256RapidContext.Widget.Popup.prototype._handleKeyDown = function (evt) {
257    this.show();
258    switch (evt.key) {
259    case "ArrowUp":
260    case "ArrowDown":
261        evt.preventDefault();
262        evt.stopImmediatePropagation();
263        this.selectMove(evt.key == "ArrowUp" ? -1 : 1);
264        break;
265    case "Escape":
266        evt.preventDefault();
267        evt.stopImmediatePropagation();
268        this.hide();
269        break;
270    case "Tab":
271    case "Enter":
272        evt.preventDefault();
273        evt.stopImmediatePropagation();
274        if (this.selectedChild()) {
275            let detail = { menu: this, item: this.selectedChild() };
276            this.emit("menuselect", { detail });
277        }
278        break;
279    }
280};
281