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 dialog widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {string} [attrs.title] the dialog title, defaults to "Dialog"
27 * @param {boolean} [attrs.modal] the modal dialog flag, defaults to `false`
28 * @param {boolean} [attrs.system] the system dialog flag, implies modal,
29 * defaults to `false`
30 * @param {boolean} [attrs.center] the center dialog flag, defaults to `true`
31 * @param {boolean} [attrs.closeable] the closeable dialog flag, defaults to
32 * `true`
33 * @param {boolean} [attrs.resizeable] the resize dialog flag, defaults to
34 * `true`
35 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to `true`
36 * @param {...(Node|Widget|string)} [child] the child widgets or DOM nodes
37 *
38 * @return {Widget} the widget DOM node
39 *
40 * @class The dialog widget class. Used to provide a resizeable and
41 * moveable window within the current page. Internally it uses a
42 * number of `<div>` HTML elements.
43 * @extends RapidContext.Widget
44 *
45 * @example <caption>JavaScript</caption>
46 * let h1 = RapidContext.UI.H1({}, "Hello, world!");
47 * let attrs = { title: "Hello", modal: true };
48 * let helloDialog = RapidContext.Widget.Dialog(attrs, h1);
49 *
50 * @example <caption>User Interface XML</caption>
51 * <Dialog id="helloDialog" title="Hello" modal="true" w="200" h="75">
52 * <h1>Hello, world!</h1>
53 * </Dialog>
54 */
55RapidContext.Widget.Dialog = function (attrs/*, ... */) {
56 let DIV = RapidContext.UI.DIV;
57 let title = DIV({ "class": "widgetDialogTitle", "data-dialog": "move" }, "Dialog");
58 let close = RapidContext.Widget.Icon({
59 "class": "widgetDialogClose fa fa-times",
60 "title": "Close",
61 "data-dialog": "close"
62 });
63 let resize = DIV({ "class": "widgetDialogResize", "data-dialog": "resize" });
64 let content = DIV({ "class": "widgetDialogContent" });
65 let o = DIV({}, title, close, resize, content);
66 RapidContext.Widget._widgetMixin(o, RapidContext.Widget.Dialog);
67 o.classList.add("widgetDialog");
68 o._setHidden(true);
69 let def = { modal: false, system: false, center: true, resizeable: true, closeable: true };
70 o.setAttrs(Object.assign(def, attrs));
71 o.addAll(Array.from(arguments).slice(1));
72 o.on("click", "[data-dialog]", o._handleClick);
73 o.on("mousedown", "[data-dialog]", o._handleMouseDown);
74 return o;
75};
76
77// Register widget class
78RapidContext.Widget.Classes.Dialog = RapidContext.Widget.Dialog;
79
80/**
81 * Emitted when the dialog is shown.
82 *
83 * @name RapidContext.Widget.Dialog#onshow
84 * @event
85 */
86
87/**
88 * Emitted when the dialog is hidden.
89 *
90 * @name RapidContext.Widget.Dialog#onhide
91 * @event
92 */
93
94/**
95 * Emitted when the dialog is moved. The event will be sent
96 * repeatedly when moving with a mouse drag operation.
97 *
98 * @name RapidContext.Widget.Dialog#onmove
99 * @event
100 */
101
102/**
103 * Emitted when the dialog is resized. The event will be sent
104 * repeatedly when resizing with a mouse drag operation.
105 *
106 * @name RapidContext.Widget.Dialog#onresize
107 * @event
108 */
109
110/**
111 * Returns the widget container DOM node.
112 *
113 * @return {Node} the container DOM node
114 */
115RapidContext.Widget.Dialog.prototype._containerNode = function () {
116 return this.lastChild;
117};
118
119/**
120 * Returns the widget style DOM node.
121 *
122 * @return {Node} the style DOM node
123 */
124RapidContext.Widget.Dialog.prototype._styleNode = function () {
125 return this.lastChild;
126};
127
128/**
129 * Handles click events in the dialog. Will close the dialog if an element
130 * with `data-dialog="close"` attribute was clicked.
131 *
132 * @param {Event} evt the DOM Event object
133 */
134RapidContext.Widget.Dialog.prototype._handleClick = function (evt) {
135 if (evt.delegateTarget.dataset.dialog == "close") {
136 this.hide();
137 }
138};
139
140/**
141 * Handles mouse down events in the dialog. Will start move or resize actions
142 * if an element with a `data-dialog="move"` or `data-dialog="resize"`
143 * attribute was clicked.
144 *
145 * @param {Event} evt the DOM Event object
146 */
147RapidContext.Widget.Dialog.prototype._handleMouseDown = function (evt) {
148 let action = evt.delegateTarget.dataset.dialog;
149 if (action == "move" || action == "resize") {
150 evt.preventDefault();
151 let isDim = action == "resize";
152 let x = (isDim ? this.offsetWidth : this.offsetLeft) - evt.pageX;
153 let y = (isDim ? this.offsetHeight : this.offsetTop) - evt.pageY;
154 document._drag = { target: this, action: action, x: x, y: y };
155 document.addEventListener("mouseup", this._handleMouseUp);
156 document.addEventListener("mousemove", this._handleMouseMove);
157 }
158};
159
160/**
161 * Stops a dialog resize or move drag operation and removes event listeners.
162 * Note that this event handler is attached to the root `document`.
163 *
164 * @param {Event} evt the DOM Event object
165 */
166RapidContext.Widget.Dialog.prototype._handleMouseUp = function (evt) {
167 let o = document._drag;
168 if (o && o.target) {
169 // FIXME: Use AbortSignal instead to disconnect
170 document.removeEventListener("mouseup", o.target._handleMouseUp);
171 document.removeEventListener("mousemove", o.target._handleMouseMove);
172 }
173 delete document._drag;
174};
175
176/**
177 * Handles a dialog move drag operation.
178 *
179 * @param {Event} evt the DOM Event object
180 */
181RapidContext.Widget.Dialog.prototype._handleMouseMove = function (evt) {
182 let o = document._drag;
183 if (o && o.action == "move") {
184 o.target.moveTo(o.x + evt.pageX, o.y + evt.pageY);
185 } else if (o && o.action == "resize") {
186 o.target.resizeTo(o.x + evt.pageX, o.y + evt.pageY);
187 }
188};
189
190/**
191 * Updates the dialog or HTML DOM node attributes.
192 *
193 * @param {Object} attrs the widget and node attributes to set
194 * @param {string} [attrs.title] the dialog title
195 * @param {boolean} [attrs.modal] the modal dialog flag
196 * @param {boolean} [attrs.system] the system dialog flag, implies modal
197 * @param {boolean} [attrs.center] the center dialog flag
198 * @param {boolean} [attrs.closeable] the closeable dialog flag
199 * @param {boolean} [attrs.resizeable] the resize dialog flag
200 * @param {boolean} [attrs.hidden] the hidden widget flag
201 */
202RapidContext.Widget.Dialog.prototype.setAttrs = function (attrs) {
203 attrs = Object.assign({}, attrs);
204 if ("title" in attrs) {
205 this.firstChild.innerText = attrs.title || "";
206 delete attrs.title;
207 }
208 if ("modal" in attrs) {
209 attrs.modal = RapidContext.Data.bool(attrs.modal);
210 }
211 if ("system" in attrs) {
212 attrs.system = RapidContext.Data.bool(attrs.system);
213 this.classList.toggle("system", attrs.system);
214 }
215 if ("center" in attrs) {
216 attrs.center = RapidContext.Data.bool(attrs.center);
217 }
218 if ("resizeable" in attrs) {
219 attrs.resizeable = RapidContext.Data.bool(attrs.resizeable);
220 this.childNodes[2].classList.toggle("hidden", !attrs.resizeable);
221 }
222 if ("closeable" in attrs) {
223 attrs.closeable = RapidContext.Data.bool(attrs.closeable);
224 this.childNodes[1].setAttrs({ hidden: !attrs.closeable });
225 }
226 if ("hidden" in attrs) {
227 this._setHiddenDialog(RapidContext.Data.bool(attrs.hidden));
228 delete attrs.hidden;
229 }
230 this.__setAttrs(attrs);
231};
232
233/**
234 * Performs the changes corresponding to setting the `hidden`
235 * widget attribute for the Dialog widget.
236 *
237 * @param {boolean} value the new attribute value
238 */
239RapidContext.Widget.Dialog.prototype._setHiddenDialog = function (value) {
240 if (!!value === this.isHidden()) {
241 // Avoid repetitive show/hide calls
242 } else if (value) {
243 if (this._modalNode != null) {
244 RapidContext.Widget.destroyWidget(this._modalNode);
245 this._modalNode = null;
246 }
247 this.blurAll();
248 this._setHidden(true);
249 this.emit("hide");
250 } else {
251 if (this.parentNode == null) {
252 throw new Error("Cannot show Dialog widget without setting a parent DOM node");
253 }
254 if (this.modal || this.system) {
255 let attrs = { loading: false, message: "", style: { "z-index": "99" } };
256 if (this.system) {
257 attrs.dark = true;
258 }
259 this._modalNode = RapidContext.Widget.Overlay(attrs);
260 this.parentNode.append(this._modalNode);
261 }
262 this._setHidden(false);
263 this.resetScroll();
264 this._resizeContent();
265 this.emit("show");
266 }
267};
268
269/**
270 * Moves the dialog to the specified position (relative to the
271 * parent DOM node). The position will be restrained by the parent
272 * DOM node size.
273 *
274 * @param {number} x the horizontal position (in pixels)
275 * @param {number} y the vertical position (in pixels)
276 */
277RapidContext.Widget.Dialog.prototype.moveTo = function (x, y) {
278 let max = {
279 x: this.parentNode.offsetWidth - this.offsetWidth - 2,
280 y: this.parentNode.offsetHeight - this.offsetHeight - 2
281 };
282 let pos = {
283 x: Math.round(Math.max(0, Math.min(x, max.x))),
284 y: Math.round(Math.max(0, Math.min(y, max.y)))
285 };
286 this.style.left = pos.x + "px";
287 this.style.top = pos.y + "px";
288 let el = this.lastChild;
289 el.style.maxWidth = (this.parentNode.offsetWidth - pos.x - el.offsetLeft - 5) + "px";
290 el.style.maxHeight = (this.parentNode.offsetHeight - pos.y - el.offsetTop - 5) + "px";
291 this.center = false;
292 this.emit("move", { detail: pos });
293};
294
295/**
296 * Moves the dialog to the apparent center (relative to the parent DOM
297 * node). The vertical position actually uses the golden ratio instead
298 * of the geometric center for improved visual alignment.
299 */
300RapidContext.Widget.Dialog.prototype.moveToCenter = function () {
301 this.style.left = "0px";
302 this.style.top = "0px";
303 this.lastChild.style.maxWidth = "";
304 this.lastChild.style.maxHeight = "";
305 let x = (this.parentNode.offsetWidth - this.offsetWidth) / 2;
306 let y = (this.parentNode.offsetHeight - this.offsetHeight) / 2.618;
307 this.moveTo(x, y);
308 this.center = true;
309};
310
311/**
312 * Resizes the dialog to the specified size (in pixels). The size
313 * will be restrained by the parent DOM node size.
314 *
315 * @param {number} w the width (in pixels)
316 * @param {number} h the height (in pixels)
317 *
318 * @return {Dimensions} an object with "w" and "h" properties for the
319 * actual size used
320 */
321RapidContext.Widget.Dialog.prototype.resizeTo = function (w, h) {
322 let max = {
323 w: this.parentNode.offsetWidth - this.offsetLeft - 2,
324 h: this.parentNode.offsetHeight - this.offsetTop - 2
325 };
326 let dim = {
327 w: Math.round(Math.max(150, Math.min(w, max.w))),
328 h: Math.round(Math.max(100, Math.min(h, max.h)))
329 };
330 this.style.width = dim.w + "px";
331 this.style.height = dim.h + "px";
332 this.center = false;
333 this._resizeContent();
334 this.emit("resize", { detail: dim });
335 return dim;
336};
337
338/**
339 * Resizes the dialog to the optimal size for the current content.
340 * Note that the size reported by the content may vary depending on
341 * if it has already been displayed, is absolutely positioned, etc.
342 * The size will be restrained by the parent DOM node size.
343 *
344 * @return {Dimensions} an object with "w" and "h" properties for the
345 * actual size used
346 */
347RapidContext.Widget.Dialog.prototype.resizeToContent = function () {
348 let el = this.lastChild;
349 let w = Math.max(el.scrollWidth, el.offsetWidth) + 2;
350 let h = Math.max(el.scrollHeight, el.offsetHeight) + el.offsetTop + 2;
351 return this.resizeTo(w, h);
352};
353
354/**
355 * Called when dialog content should be resized.
356 */
357RapidContext.Widget.Dialog.prototype._resizeContent = function () {
358 if (!this.isHidden()) {
359 if (this.center) {
360 this.moveToCenter();
361 }
362 }
363};
364
365/**
366 * Resets the scroll offsets for all child elements in the dialog.
367 */
368RapidContext.Widget.Dialog.prototype.resetScroll = function () {
369 function scrollReset(el) {
370 el.scrollTop = 0;
371 el.scrollLeft = 0;
372 }
373 Array.from(this.querySelectorAll("*")).forEach(scrollReset);
374};
375
376// Add global keydown handler
377window.addEventListener("DOMContentLoaded", () => {
378 document.body.addEventListener("keydown", (evt) => {
379 if (evt.key == "Escape") {
380 let selector = ".widgetDialog, .widgetPopup";
381 let isVisible = (el) => el.offsetHeight > 0;
382 let elems = Array.from(document.body.querySelectorAll(selector)).filter(isVisible);
383 let last = elems[elems.length - 1];
384 last && last.closeable && last.hide();
385 }
386 });
387});
388
RapidContext
Access · Discovery · Insight
www.rapidcontext.com