Fossil SCM

fossil-scm / src / fossil.confirmer.js
Blame History Raw 332 lines
1
"use strict";
2
/**************************************************************
3
Confirmer is a utility which provides an alternative to confirmation
4
dialog boxes and "check this checkbox to confirm action" widgets. It
5
acts by modifying a button to require two clicks within a certain
6
time, with the second click acting as a confirmation of the first. If
7
the second click does not come within a specified timeout then the
8
action is not confirmed.
9
10
Usage:
11
12
fossil.confirmer(domElement, options);
13
14
Usually:
15
16
fossil.confirmer(element, {
17
onconfirm: function(){
18
// this === the element.
19
// Do whatever the element would normally do when
20
// clicked.
21
}
22
});
23
24
Options:
25
26
.initialText = initial text of the element. Defaults to the result
27
of the element's .value (for INPUT tags) or innerHTML (for
28
everything else). After the timeout/tick count expires, or if the
29
user confirms the operation, the element's text is re-set to this
30
value.
31
32
.confirmText = text to show when in "confirm mode".
33
Default=("Confirm: "+initialText), or something similar.
34
35
.timeout = Number of milliseconds to wait for confirmation.
36
Default=3000. Alternately, use a combination of .ticks and
37
.ticktime.
38
39
.onconfirm = function to call when clicked in confirm mode. Default
40
= undefined. The function's "this" is the DOM element to which
41
the countdown applies.
42
43
.ontimeout = function to call when confirm is not issued. Default =
44
undefined. The function's "this" is the DOM element to which the
45
countdown applies.
46
47
.onactivate = function to call when item is clicked, but only if the
48
item is not currently in countdown mode. This is called (and must
49
return) before the countdown starts. The function's "this" is the
50
DOM element to which the countdown applies. This can be used, e.g.,
51
to change the element's text or CSS classes.
52
53
.classInitial = optional CSS class string (default='') which is
54
added to the element during its "initial" state (the state it is in
55
when it is not waiting on a timeout). When the target is activated
56
(waiting on a timeout) this class is removed. In the case of a
57
timeout, this class is added *before* the .ontimeout handler is
58
called.
59
60
.classWaiting = optional CSS class string (default='') which is
61
added to the target when it is waiting on a timeout. When the target
62
leaves timeout-wait mode, this class is removed. When timeout-wait
63
mode is entered, this class is added *before* the .onactivate
64
handler is called.
65
66
.ticktime = a number of ms to wait per tick (see the next item).
67
Default = 1000.
68
69
.ticks = a number of "ticks" to wait, as an alternative to .timeout.
70
When this mode is active, the ontick callback will be triggered
71
immediately before each tick, including the first one. If both
72
.ticks and .timeout are set, only one will be used, but which one is
73
unspecified. If passed a ticks value with a truncated integer value
74
of 0 or less, it will throw an exception (e.g. that also applies if
75
it's passed 0.5).
76
77
.ontick = when using .ticks, this callback is passed the current
78
tick number before each tick, and its "this" is the target
79
element. On each subsequent call, the tick count will be reduced by
80
1, and it is passed 0 after the final tick expires or when the
81
action has been confirmed, immediately before the onconfirm or
82
ontimeout callback. The intention of the callback is to update the
83
label of the target element. If .ticks is set but .ontick is not
84
then a default implementation is used which updates the element with
85
the .confirmText, prepending a countdown to it.
86
87
.pinSize = if true AND confirmText is set, calculate the larger of
88
the element's original and confirmed size and pin it to the larger
89
of those sizes to avoid layout reflows when confirmation is
90
running. The pinning is implemented by setting its minWidth and
91
maxWidth style properties to the same value. This does not work if
92
the element text is updated dynamically via ontick(). This ONLY
93
works if the element is in the DOM and is not hidden (e.g. via
94
display:none) at the time this routine is called, otherwise we
95
cannot calculate its size. If the element needs to be hidden, hide
96
it after initializing the confirmer.
97
98
.debug = boolean. If truthy, it sends some debug output to the dev
99
console to track what it's doing.
100
101
Various notes:
102
103
- To change the default option values, modify the
104
fossil.confirmer.defaultOpts object.
105
106
- Exceptions triggered via the callbacks are caught and emitted to the
107
dev console if the debug option is enabled, but are otherwise
108
ignored.
109
110
- Due to the nature of multi-threaded code, it is potentially possible
111
that confirmation and timeout actions BOTH happen if the user
112
triggers the associated action at "just the right millisecond"
113
before the timeout is triggered.
114
115
TODO:
116
117
- Add an invert option which activates if the timeout is reached and
118
"times out" if the element is clicked again. e.g. a button which says
119
"Saving..." and cancels the op if it's clicked again, else it saves
120
after X time/ticks.
121
122
- Internally we save/restore the initial text of non-INPUT elements
123
using a relatively expensive bit of DOMParser hoop-jumping. We
124
"should" instead move their child nodes aside (into an internal
125
out-of-DOM element) and restore them as needed.
126
127
Terse Change history:
128
129
- 20200811
130
- Added pinSize option.
131
132
- 20200507:
133
- Add a tick-based countdown in order to more easily support
134
updating the target element with the countdown.
135
136
- 20200506:
137
- Ported from jQuery to plain JS.
138
139
- 20181112:
140
- extended to support certain INPUT elements.
141
- made default opts configurable.
142
143
- 20070717: initial jQuery-based impl.
144
*/
145
(function(F/*the fossil object*/){
146
F.confirmer = function f(elem,opt){
147
const dbg = opt.debug
148
? function(){console.debug.apply(console,arguments)}
149
: function(){};
150
dbg("confirmer opt =",opt);
151
if(!f.Holder){
152
f.isInput = (e)=>/^(input|textarea)$/i.test(e.nodeName);
153
f.Holder = function(target,opt){
154
const self = this;
155
this.target = target;
156
this.opt = opt;
157
this.timerID = undefined;
158
this.state = this.states.initial;
159
const isInput = f.isInput(target);
160
const updateText = function(msg){
161
if(isInput) target.value = msg;
162
else{
163
/* Jump through some hoops to avoid assigning to innerHTML... */
164
const newNode = new DOMParser().parseFromString(msg, 'text/html');
165
let childs = newNode.documentElement.querySelector('body');
166
childs = childs ? Array.prototype.slice.call(childs.childNodes, 0) : [];
167
target.innerText = '';
168
childs.forEach((e)=>target.appendChild(e));
169
}
170
}
171
const formatCountdown = (txt, number) => txt + " ["+number+"]";
172
if(opt.pinSize && opt.confirmText){
173
/* Try to pin the element's width to the greater of its
174
current width or its waiting-on-confirmation width
175
to avoid layout reflow when it's activated. */
176
const digits = (''+(opt.timeout/1000 || opt.ticks)).length;
177
const lblLong = formatCountdown(opt.confirmText, "00000000".substr(0,digits+1));
178
const w1 = parseInt(target.getBoundingClientRect().width);
179
updateText(lblLong);
180
const w2 = parseInt(target.getBoundingClientRect().width);
181
if(w1 || w2){
182
/* If target is not in visible part of the DOM, those values may be 0. */
183
target.style.minWidth = target.style.maxWidth = (w1>w2 ? w1 : w2)+"px";
184
}
185
}
186
updateText(this.opt.initialText);
187
if(this.opt.ticks && !this.opt.ontick){
188
this.opt.ontick = function(tick){
189
updateText(formatCountdown(self.opt.confirmText,tick));
190
};
191
}
192
this.setClasses(false);
193
this.doTimeout = function() {
194
if(this.timerID){
195
clearTimeout( this.timerID );
196
delete this.timerID;
197
}
198
if( this.state != this.states.waiting ) {
199
// it was already confirmed
200
return;
201
}
202
this.setClasses( false );
203
this.state = this.states.initial;
204
dbg("Timeout triggered.");
205
if( this.opt.ontick ){
206
try{this.opt.ontick.call(this.target, 0)}
207
catch(e){dbg("ontick EXCEPTION:",e)}
208
}
209
if( this.opt.ontimeout ) {
210
try{this.opt.ontimeout.call(this.target)}
211
catch(e){dbg("ontimeout EXCEPTION:",e)}
212
}
213
updateText(this.opt.initialText);
214
};
215
target.addEventListener(
216
'click', function(){
217
switch( self.state ) {
218
case( self.states.waiting ):
219
/* Cancel the wait on confirmation */
220
if( undefined !== self.timerID ){
221
clearTimeout( self.timerID );
222
delete self.timerID;
223
}
224
self.state = self.states.initial;
225
self.setClasses( false );
226
dbg("Confirmed");
227
if( self.opt.ontick ){
228
try{self.opt.ontick.call(self.target,0)}
229
catch(e){dbg("ontick EXCEPTION:",e)}
230
}
231
if( self.opt.onconfirm ){
232
try{self.opt.onconfirm.call(self.target)}
233
catch(e){dbg("onconfirm EXCEPTION:",e)}
234
}
235
updateText(self.opt.initialText);
236
break;
237
case( self.states.initial ):
238
/* Enter the waiting-on-confirmation state... */
239
if(self.opt.ticks) self.opt.currentTick = self.opt.ticks;
240
self.setClasses( true );
241
self.state = self.states.waiting;
242
updateText( self.opt.confirmText );
243
if( self.opt.onactivate ) self.opt.onactivate.call( self.target );
244
if( self.opt.ontick ) self.opt.ontick.call(self.target, self.opt.currentTick);
245
if(self.opt.timeout){
246
dbg("Waiting "+self.opt.timeout+"ms on confirmation...");
247
self.timerID =
248
setTimeout(()=>self.doTimeout(),self.opt.timeout );
249
}else if(self.opt.ticks){
250
dbg("Waiting on confirmation for "+self.opt.ticks
251
+" ticks of "+self.opt.ticktime+"ms each...");
252
self.timerID =
253
setInterval(function(){
254
if(0===--self.opt.currentTick) self.doTimeout();
255
else{
256
try{self.opt.ontick.call(self.target,
257
self.opt.currentTick)}
258
catch(e){dbg("ontick EXCEPTION:",e)}
259
}
260
},self.opt.ticktime);
261
}
262
break;
263
default: // can't happen.
264
break;
265
}
266
}, false
267
);
268
};
269
f.Holder.prototype = {
270
states:{initial: 0, waiting: 1},
271
setClasses: function(activated) {
272
if(activated) {
273
if( this.opt.classWaiting ) {
274
this.target.classList.add( this.opt.classWaiting );
275
}
276
if( this.opt.classInitial ) {
277
this.target.classList.remove( this.opt.classInitial );
278
}
279
}else{
280
if( this.opt.classInitial ) {
281
this.target.classList.add( this.opt.classInitial );
282
}
283
if( this.opt.classWaiting ) {
284
this.target.classList.remove( this.opt.classWaiting );
285
}
286
}
287
}
288
};
289
}/*static init*/
290
opt = F.mergeLastWins(f.defaultOpts,{
291
initialText: (
292
f.isInput(elem) ? elem.value : elem.innerHTML
293
) || "PLEASE SET .initialText"
294
},opt);
295
if(!opt.confirmText){
296
opt.confirmText = "Confirm: "+opt.initialText;
297
}
298
if(opt.ticks){
299
delete opt.timeout;
300
opt.ticks = 0 | opt.ticks /* ensure it's an integer */;
301
if(opt.ticks<=0){
302
throw new Error("ticks must be >0");
303
}
304
if(opt.ticktime <= 0) opt.ticktime = 1000;
305
}else{
306
delete opt.ontick;
307
delete opt.ticks;
308
}
309
new f.Holder(elem,opt);
310
return this;
311
};
312
/**
313
The default options for initConfirmer(). Tweak them to set the
314
defaults. A couple of them (initialText and confirmText) are
315
dynamically-generated, and can't reasonably be set in the
316
defaults. Some, like ticks, cannot be set here because that would
317
end up indirectly replacing non-tick timeouts with ticks.
318
*/
319
F.confirmer.defaultOpts = {
320
timeout:undefined,
321
ticks: 3,
322
ticktime: 998/*not *quite* 1000*/,
323
onconfirm: undefined,
324
ontimeout: undefined,
325
onactivate: undefined,
326
classInitial: '',
327
classWaiting: '',
328
debug: false
329
};
330
331
})(window.fossil);
332

Keyboard Shortcuts

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