Fossil SCM

fossil-scm / src / fossil.popupwidget.js
Blame History Raw 472 lines
1
(function(F/*fossil object*/){
2
/**
3
A very basic tooltip-like widget. It's intended to be popped up
4
to display basic information or basic user interaction
5
components, e.g. a copy-to-clipboard button.
6
7
Requires: fossil.bootstrap, fossil.dom
8
*/
9
const D = F.dom;
10
11
/**
12
Creates a new tooltip-like widget using the given options object.
13
14
Options:
15
16
.refresh: callback which is called just before the tooltip is
17
revealed. It must refresh the contents of the tooltip, if needed,
18
by applying the content to/within this.e, which is the base DOM
19
element for the tooltip (and is a child of document.body). If the
20
contents are static and set up via the .init option then this
21
callback is not needed. When moving an already-shown tooltip,
22
this is *not* called. It arguably should be, but the fact is that
23
we often have to show() a popup twice in a row without hiding it
24
between those calls: once to get its computed size and another to
25
move it by some amount relative to that size. If the state of the
26
popup depends on its position and a "double-show()" is needed
27
then the client must hide() the popup between the two calls to
28
show() in order to force a call to refresh() on the second
29
show().
30
31
.adjustX: an optional callback which is called when the tooltip
32
is to be displayed at a given position and passed the X
33
viewport-relative coordinate. This routine must either return its
34
argument as-is or return an adjusted value. The intent is to
35
allow a given tooltip may be positioned more appropriately for a
36
given context, if needed (noting that the desired position can,
37
and probably should, be passed to the show() method
38
instead). This class's API assumes that clients give it
39
viewport-relative coordinates, and it will take care to translate
40
those to page-relative, so this callback should not do so.
41
42
.adjustY: the Y counterpart of adjustX.
43
44
.init: optional callback called one time to initialize the state
45
of the tooltip. This is called after the this.e has been created
46
and added (initially hidden) to the DOM. If this is called, it is
47
removed from the object immediately after it is called.
48
49
All callback options are called with the PopupWidget object as
50
their "this".
51
52
53
.cssClass: optional CSS class, or list of classes, to apply to
54
the new element. In addition to any supplied here (or inherited
55
from the default), the class "fossil-PopupWidget" is always set
56
in order to allow certain app-internal CSS to account for popup
57
windows in special cases.
58
59
.style: optional object of properties to copy directly into
60
the element's style object.
61
62
The options passed to this constructor get normalized into a
63
separate object which includes any default values for options not
64
provided by the caller. That object is available this the
65
resulting PopupWidget's options property. Default values for any
66
options not provided by the caller are pulled from
67
PopupWidget.defaultOptions, and modifying those affects all
68
future calls to this method but has no effect on existing
69
instances.
70
71
72
Example:
73
74
const tip = new fossil.PopupWidget({
75
init: function(){
76
// optionally populate DOM element this.e with the widget's
77
// content.
78
},
79
refresh: function(){
80
// (re)populate/refresh the contents of the main
81
// wrapper element, this.e.
82
}
83
});
84
85
tip.show(50, 100);
86
// ^^^ viewport-relative coordinates. See show() for other options.
87
88
*/
89
F.PopupWidget = function f(opt){
90
opt = F.mergeLastWins(f.defaultOptions,opt);
91
this.options = opt;
92
const e = this.e = D.addClass(D.div(), opt.cssClass,
93
"fossil-PopupWidget");
94
this.show(false);
95
if(opt.style){
96
let k;
97
for(k in opt.style){
98
if(opt.style.hasOwnProperty(k)) e.style[k] = opt.style[k];
99
}
100
}
101
D.append(document.body, e/*must be in the DOM for size calc. to work*/);
102
D.copyStyle(e, opt.style);
103
if(opt.init){
104
opt.init.call(this);
105
delete opt.init;
106
}
107
};
108
109
/**
110
Default options for the PopupWidget constructor. These values are
111
used for any options not provided by the caller. Any changes made
112
to this instace affect future calls to PopupWidget() but have no
113
effect on existing instances.
114
*/
115
F.PopupWidget.defaultOptions = {
116
cssClass: 'fossil-tooltip',
117
style: undefined /*{optional properties copied as-is into element.style}*/,
118
adjustX: (x)=>x,
119
adjustY: (y)=>y,
120
refresh: function(){},
121
init: undefined /* optional initialization function */
122
};
123
124
F.PopupWidget.prototype = {
125
126
/** Returns true if the widget is currently being shown, else false. */
127
isShown: function(){return !this.e.classList.contains('hidden')},
128
129
/** Calls the refresh() method of the options object and returns
130
this object. */
131
refresh: function(){
132
if(this.options.refresh){
133
this.options.refresh.call(this);
134
}
135
return this;
136
},
137
138
/**
139
Shows or hides the tooltip.
140
141
Usages:
142
143
(bool showIt) => hide it or reveal it at its last position.
144
145
(x, y) => reveal/move it at/to the given
146
relative-to-the-viewport position, which will be adjusted to make
147
it page-relative.
148
149
(DOM element) => reveal/move it at/to a position based on the
150
the given element (adjusted slightly).
151
152
For the latter two, this.options.adjustX() and adjustY() will
153
be called to adjust it further.
154
155
Returns this object.
156
157
If this call will reveal the element then it calls
158
this.refresh() to update the UI state. If the element was
159
already revealed, the call to refresh() is skipped.
160
161
Sidebar: showing/hiding the widget is, as is conventional for
162
this framework, done by removing/adding the 'hidden' CSS class
163
to it, so that class must be defined appropriately.
164
*/
165
show: function(){
166
var x = undefined, y = undefined, showIt,
167
wasShown = !this.e.classList.contains('hidden');
168
if(2===arguments.length){
169
x = arguments[0];
170
y = arguments[1];
171
showIt = true;
172
}else if(1===arguments.length){
173
if(arguments[0] instanceof HTMLElement){
174
const p = arguments[0];
175
const r = p.getBoundingClientRect();
176
x = r.x + r.x/5;
177
y = r.y - r.height/2;
178
showIt = true;
179
}else{
180
showIt = !!arguments[0];
181
}
182
}
183
if(showIt){
184
if(!wasShown) this.refresh();
185
x = this.options.adjustX.call(this,x);
186
y = this.options.adjustY.call(this,y);
187
x += window.pageXOffset;
188
y += window.pageYOffset;
189
}
190
if(showIt){
191
if('number'===typeof x && 'number'===typeof y){
192
this.e.style.left = x+"px";
193
this.e.style.top = y+"px";
194
}
195
D.removeClass(this.e, 'hidden');
196
}else{
197
D.addClass(this.e, 'hidden');
198
this.e.style.removeProperty('left');
199
this.e.style.removeProperty('top');
200
}
201
return this;
202
},
203
204
/**
205
Equivalent to show(false), but may be overridden by instances,
206
so long as they also call this.show(false) to perform the
207
actual hiding. Overriding can be used to clean up any state so
208
that the next call to refresh() (before the popup is show()n
209
again) can recognize whether it needs to do something, noting
210
that it's legal, and sometimes necessary, to call show()
211
multiple times without needing/wanting to completely refresh
212
the popup between each call (e.g. when moving the popup after
213
it's been show()n).
214
*/
215
hide: function(){return this.show(false)},
216
217
/**
218
A convenience method which adds click handlers to this popup's
219
main element and document.body to hide (via hide()) the popup
220
when either element is clicked or the ESC key is pressed. Only
221
call this once per instance, if at all. Returns this;
222
223
The first argument specifies whether a click handler on this
224
object is installed. The second specifies whether a click
225
outside of this object should close it. The third specifies
226
whether an ESC handler is installed.
227
228
Passing no arguments is equivalent to passing (true,true,true),
229
and passing fewer arguments defaults the unpassed parameters to
230
true.
231
*/
232
installHideHandlers: function f(onClickSelf, onClickOther, onEsc){
233
if(!arguments.length) onClickSelf = onClickOther = onEsc = true;
234
else if(1===arguments.length) onClickOther = onEsc = true;
235
else if(2===arguments.length) onEsc = true;
236
if(onClickSelf) this.e.addEventListener('click', ()=>this.hide(), false);
237
if(onClickOther) document.body.addEventListener('click', ()=>this.hide(), true);
238
if(onEsc){
239
const self = this;
240
document.body.addEventListener('keydown', function(ev){
241
if(self.isShown() && 27===ev.which) self.hide();
242
}, true);
243
}
244
return this;
245
}
246
}/*F.PopupWidget.prototype*/;
247
248
/**
249
Internal impl for F.toast() and friends.
250
251
args:
252
253
1) CSS class to assign to the outer element, along with
254
fossil-toast-message. Must be falsy for the non-warning/non-error
255
case.
256
257
2) Multiplier of F.toast.config.displayTimeMs. Should be
258
1 for default case and progressively higher for warning/error
259
cases.
260
261
3) The 'arguments' object from the function which is calling
262
this.
263
264
Returns F.toast.
265
*/
266
const toastImpl = function f(cssClass, durationMult, argsObject){
267
if(!f.toaster){
268
f.toaster = new F.PopupWidget({
269
cssClass: 'fossil-toast-message'
270
});
271
D.attr(f.toaster.e, 'role', 'alert');
272
}
273
const T = f.toaster;
274
if(f._timer) clearTimeout(f._timer);
275
D.clearElement(T.e);
276
if(f._prevCssClass) T.e.classList.remove(f._prevCssClass);
277
if(cssClass) T.e.classList.add(cssClass);
278
f._prevCssClass = cssClass;
279
D.append(T.e, Array.prototype.slice.call(argsObject,0));
280
T.show(F.toast.config.position.x, F.toast.config.position.y);
281
f._timer = setTimeout(
282
()=>T.hide(),
283
F.toast.config.displayTimeMs * durationMult
284
);
285
return F.toast;
286
};
287
288
F.toast = {
289
config: {
290
position: { x: 5, y: 5 /*viewport-relative, pixels*/ },
291
displayTimeMs: 5000
292
},
293
/**
294
Convenience wrapper around a PopupWidget which pops up a shared
295
PopupWidget instance to show toast-style messages (commonly
296
seen on Android). Its arguments may be anything suitable for
297
passing to fossil.dom.append(), and each argument is first
298
append()ed to the toast widget, then the widget is shown for
299
F.toast.config.displayTimeMs milliseconds. If this is called
300
while a toast is currently being displayed, the first will be
301
overwritten and the time until the message is hidden will be
302
reset.
303
304
The toast is always shown at the viewport-relative coordinates
305
defined by the F.toast.config.position.
306
307
The toaster's DOM element has the CSS class fossil-tooltip
308
and fossil-toast-message, so can be style via those.
309
310
The 3 main message types (message, warning, error) each get a
311
CSS class with that same name added to them. Thus CSS can
312
select on .fossil-toast-message.error to style error toasts.
313
*/
314
message: function(/*...*/){
315
return toastImpl(false,1, arguments);
316
},
317
/**
318
Displays a toast with the 'warning' CSS class assigned to it. It
319
displays for 1.5 times as long as a normal toast.
320
*/
321
warning: function(/*...*/){
322
return toastImpl('warning',1.5,arguments);
323
},
324
/**
325
Displays a toast with the 'error' CSS class assigned to it. It
326
displays for twice as long as a normal toast.
327
*/
328
error: function(/*...*/){
329
return toastImpl('error',2,arguments);
330
}
331
}/*F.toast*/;
332
333
334
F.helpButtonlets = {
335
/**
336
Initializes one or more "help buttonlets". It may be passed any of:
337
338
- A string: CSS selector (multiple matches are legal)
339
340
- A single DOM element.
341
342
- A forEach-compatible container of DOM elements.
343
344
- No arguments, which is equivalent to passing the string
345
".help-buttonlet:not(.processed)".
346
347
Passing the same element(s) more than once is a no-op: during
348
initialization, each elements get the class'processed' added to
349
it, and any elements with that class are skipped.
350
351
All child nodes of a help buttonlet are removed from the button
352
during initialization and stashed away for use in a PopupWidget
353
when the botton is clicked.
354
355
*/
356
setup: function f(){
357
if(!f.hasOwnProperty('clickHandler')){
358
f.clickHandler = function fch(ev){
359
ev.preventDefault();
360
ev.stopPropagation();
361
if(!fch.popup){
362
fch.popup = new F.PopupWidget({
363
cssClass: ['fossil-tooltip', 'help-buttonlet-content'],
364
refresh: function(){
365
}
366
});
367
fch.popup.e.style.maxWidth = '80%'/*of body*/;
368
fch.popup.installHideHandlers();
369
}
370
D.append(D.clearElement(fch.popup.e), ev.target.$helpContent);
371
/* Shift the help around a bit to "better" fit the
372
screen. However, fch.popup.e.getClientRects() is empty
373
until the popup is shown, so we have to show it,
374
calculate the resulting size, then move and/or resize it.
375
376
This algorithm/these heuristics can certainly be improved
377
upon.
378
*/
379
var popupRect, rectElem = ev.target;
380
while(rectElem){
381
popupRect = rectElem.getClientRects()[0]/*undefined if off-screen!*/;
382
if(popupRect) break;
383
rectElem = rectElem.parentNode;
384
}
385
if(!popupRect) popupRect = {x:0, y:0, left:0, right:0};
386
var x = popupRect.left, y = popupRect.top;
387
if(x<0) x = 0;
388
if(y<0) y = 0;
389
if(rectElem){
390
/* Try to ensure that the popup's z-level is higher than this element's */
391
const rz = window.getComputedStyle(rectElem).zIndex;
392
var myZ;
393
if(rz && !isNaN(+rz)){
394
myZ = +rz + 1;
395
}else{
396
myZ = 10000/*guess!*/;
397
}
398
fch.popup.e.style.zIndex = myZ;
399
}
400
fch.popup.show(x, y);
401
x = popupRect.left, y = popupRect.top;
402
popupRect = fch.popup.e.getBoundingClientRect();
403
const rectBody = document.body.getClientRects()[0];
404
if(popupRect.right > rectBody.right){
405
x -= (popupRect.right - rectBody.right);
406
}
407
if(x + popupRect.width > rectBody.right){
408
x = rectBody.x + (rectBody.width*0.1);
409
fch.popup.e.style.minWidth = '70%';
410
}else{
411
fch.popup.e.style.removeProperty('min-width');
412
x -= popupRect.width/2;
413
}
414
if(x<0) x = 0;
415
//console.debug("dimensions",x,y, popupRect, rectBody);
416
fch.popup.show(x, y);
417
return false;
418
};
419
f.foreachElement = function(e){
420
if(e.classList.contains('processed')) return;
421
e.classList.add('processed');
422
e.$helpContent = [];
423
/* We have to move all child nodes out of the way because we
424
cannot hide TEXT nodes via CSS (which cannot select TEXT
425
nodes). We have to do it in two steps to avoid invaliding
426
the list during traversal. */
427
e.childNodes.forEach((ch)=>e.$helpContent.push(ch));
428
e.$helpContent.forEach((ch)=>ch.remove());
429
e.addEventListener('click', f.clickHandler, false);
430
};
431
}/*static init*/
432
var elems;
433
if(!arguments.length){
434
arguments[0] = '.help-buttonlet:not(.processed)';
435
arguments.length = 1;
436
}
437
if(arguments.length){
438
if('string'===typeof arguments[0]){
439
elems = document.querySelectorAll(arguments[0]);
440
}else if(arguments[0] instanceof HTMLElement){
441
elems = [arguments[0]];
442
}else if(arguments[0].forEach){/* assume DOM element list or array */
443
elems = arguments[0];
444
}
445
}
446
if(elems) elems.forEach(f.foreachElement);
447
},
448
449
/**
450
Sets up the given element as a "help buttonlet", adding the CSS
451
class help-buttonlet to it. Any (optional) arguments after the
452
first are appended to the element using fossil.dom.append(), so
453
that they become the content for the buttonlet's popup help.
454
455
The element is then passed to this.setup() before it
456
is returned from this function.
457
*/
458
create: function(elem/*...body*/){
459
D.addClass(elem, 'help-buttonlet');
460
if(arguments.length>1){
461
const args = Array.prototype.slice.call(arguments,1);
462
D.append(elem, args);
463
}
464
this.setup(elem);
465
return elem;
466
}
467
}/*helpButtonlets*/;
468
469
F.onDOMContentLoaded( ()=>F.helpButtonlets.setup() );
470
471
})(window.fossil);
472

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button