|
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
|
|