5a920362 |
1 | /* http://keith-wood.name/timeEntry.html |
2 | Time entry for jQuery v1.4.9. |
3 | Written by Keith Wood (kbwood{at}iinet.com.au) June 2007. |
4 | Dual licensed under the GPL (http://dev.jquery.com/browser/trunk/jquery/GPL-LICENSE.txt) and |
5 | MIT (http://dev.jquery.com/browser/trunk/jquery/MIT-LICENSE.txt) licenses. |
6 | Please attribute the author if you use it. */ |
7 | |
8 | /* Turn an input field into an entry point for a time value. |
9 | The time can be entered via directly typing the value, |
10 | via the arrow keys, or via spinner buttons. |
11 | It is configurable to show 12 or 24-hour time, to show or hide seconds, |
12 | to enforce a minimum and/or maximum time, to change the spinner image, |
13 | and to constrain the time to steps, e.g. only on the quarter hours. |
14 | Attach it with $('input selector').timeEntry(); for default settings, |
15 | or configure it with options like: |
16 | $('input selector').timeEntry( |
17 | {spinnerImage: 'spinnerSquare.png', spinnerSize: [20, 20, 0]}); */ |
18 | |
19 | (function($) { // Hide scope, no $ conflict |
20 | |
21 | /* TimeEntry manager. |
22 | Use the singleton instance of this class, $.timeEntry, to interact with the time entry |
23 | functionality. Settings for (groups of) fields are maintained in an instance object |
24 | (TimeEntryInstance), allowing multiple different settings on the same page. */ |
25 | function TimeEntry() { |
26 | this._disabledInputs = []; // List of time entry inputs that have been disabled |
27 | this.regional = []; // Available regional settings, indexed by language code |
28 | this.regional[''] = { // Default regional settings |
29 | show24Hours: false, // True to use 24 hour time, false for 12 hour (AM/PM) |
30 | separator: ':', // The separator between time fields |
31 | ampmPrefix: '', // The separator before the AM/PM text |
32 | ampmNames: ['AM', 'PM'], // Names of morning/evening markers |
33 | spinnerTexts: ['Now', 'Previous field', 'Next field', 'Increment', 'Decrement'] |
34 | // The popup texts for the spinner image areas |
35 | }; |
36 | this._defaults = { |
37 | appendText: '', // Display text following the input box, e.g. showing the format |
38 | showSeconds: false, // True to show seconds as well, false for hours/minutes only |
39 | timeSteps: [1, 1, 1], // Steps for each of hours/minutes/seconds when incrementing/decrementing |
40 | initialField: 0, // The field to highlight initially, 0 = hours, 1 = minutes, ... |
41 | useMouseWheel: true, // True to use mouse wheel for increment/decrement if possible, |
42 | // false to never use it |
43 | defaultTime: null, // The time to use if none has been set, leave at null for now |
44 | minTime: null, // The earliest selectable time, or null for no limit |
45 | maxTime: null, // The latest selectable time, or null for no limit |
46 | spinnerImage: 'spinnerDefault.png', // The URL of the images to use for the time spinner |
47 | // Seven images packed horizontally for normal, each button pressed, and disabled |
48 | spinnerSize: [20, 20, 8], // The width and height of the spinner image, |
49 | // and size of centre button for current time |
50 | spinnerBigImage: '', // The URL of the images to use for the expanded time spinner |
51 | // Seven images packed horizontally for normal, each button pressed, and disabled |
52 | spinnerBigSize: [40, 40, 16], // The width and height of the expanded spinner image, |
53 | // and size of centre button for current time |
54 | spinnerIncDecOnly: false, // True for increment/decrement buttons only, false for all |
55 | spinnerRepeat: [500, 250], // Initial and subsequent waits in milliseconds |
56 | // for repeats on the spinner buttons |
57 | beforeShow: null, // Function that takes an input field and |
58 | // returns a set of custom settings for the time entry |
59 | beforeSetTime: null // Function that runs before updating the time, |
60 | // takes the old and new times, and minimum and maximum times as parameters, |
61 | // and returns an adjusted time if necessary |
62 | }; |
63 | $.extend(this._defaults, this.regional['']); |
64 | } |
65 | |
66 | var PROP_NAME = 'timeEntry'; |
67 | |
68 | $.extend(TimeEntry.prototype, { |
69 | /* Class name added to elements to indicate already configured with time entry. */ |
70 | markerClassName: 'hasTimeEntry', |
71 | |
72 | /* Override the default settings for all instances of the time entry. |
73 | @param options (object) the new settings to use as defaults (anonymous object) |
74 | @return (DateEntry) this object */ |
75 | setDefaults: function(options) { |
76 | extendRemove(this._defaults, options || {}); |
77 | return this; |
78 | }, |
79 | |
80 | /* Attach the time entry handler to an input field. |
81 | @param target (element) the field to attach to |
82 | @param options (object) custom settings for this instance */ |
83 | _connectTimeEntry: function(target, options) { |
84 | var input = $(target); |
85 | if (input.hasClass(this.markerClassName)) { |
86 | return; |
87 | } |
88 | var inst = {}; |
89 | inst.options = $.extend({}, options); |
90 | inst._selectedHour = 0; // The currently selected hour |
91 | inst._selectedMinute = 0; // The currently selected minute |
92 | inst._selectedSecond = 0; // The currently selected second |
93 | inst._field = 0; // The selected subfield |
94 | inst.input = $(target); // The attached input field |
95 | $.data(target, PROP_NAME, inst); |
96 | var spinnerImage = this._get(inst, 'spinnerImage'); |
97 | var spinnerText = this._get(inst, 'spinnerText'); |
98 | var spinnerSize = this._get(inst, 'spinnerSize'); |
99 | var appendText = this._get(inst, 'appendText'); |
100 | var spinner = (!spinnerImage ? null : |
101 | $('<span class="timeEntry_control" style="display: inline-block; ' + |
102 | 'background: url(\'' + spinnerImage + '\') 0 0 no-repeat; ' + |
103 | 'width: ' + spinnerSize[0] + 'px; height: ' + spinnerSize[1] + 'px;' + |
104 | ($.browser.mozilla && $.browser.version < '1.9' ? // FF 2- (Win) |
105 | ' padding-left: ' + spinnerSize[0] + 'px; padding-bottom: ' + |
106 | (spinnerSize[1] - 18) + 'px;' : '') + '"></span>')); |
107 | input.wrap('<span class="timeEntry_wrap"></span>'). |
108 | after(appendText ? '<span class="timeEntry_append">' + appendText + '</span>' : ''). |
109 | after(spinner || ''); |
110 | input.addClass(this.markerClassName).bind('focus.timeEntry', this._doFocus). |
111 | bind('blur.timeEntry', this._doBlur).bind('click.timeEntry', this._doClick). |
112 | bind('keydown.timeEntry', this._doKeyDown).bind('keypress.timeEntry', this._doKeyPress); |
113 | // Check pastes |
114 | if ($.browser.mozilla) { |
115 | input.bind('input.timeEntry', function(event) { $.timeEntry._parseTime(inst); }); |
116 | } |
117 | if ($.browser.msie) { |
118 | input.bind('paste.timeEntry', |
119 | function(event) { setTimeout(function() { $.timeEntry._parseTime(inst); }, 1); }); |
120 | } |
121 | // Allow mouse wheel usage |
122 | if (this._get(inst, 'useMouseWheel') && $.fn.mousewheel) { |
123 | input.mousewheel(this._doMouseWheel); |
124 | } |
125 | if (spinner) { |
126 | spinner.mousedown(this._handleSpinner).mouseup(this._endSpinner). |
127 | mouseover(this._expandSpinner).mouseout(this._endSpinner). |
128 | mousemove(this._describeSpinner); |
129 | } |
130 | }, |
131 | |
132 | /* Enable a time entry input and any associated spinner. |
133 | @param input (element) single input field */ |
134 | _enableTimeEntry: function(input) { |
135 | this._enableDisable(input, false); |
136 | }, |
137 | |
138 | /* Disable a time entry input and any associated spinner. |
139 | @param input (element) single input field */ |
140 | _disableTimeEntry: function(input) { |
141 | this._enableDisable(input, true); |
142 | }, |
143 | |
144 | /* Enable or disable a time entry input and any associated spinner. |
145 | @param input (element) single input field |
146 | @param disable (boolean) true to disable, false to enable */ |
147 | _enableDisable: function(input, disable) { |
148 | var inst = $.data(input, PROP_NAME); |
149 | if (!inst) { |
150 | return; |
151 | } |
152 | input.disabled = disable; |
153 | if (input.nextSibling && input.nextSibling.nodeName.toLowerCase() == 'span') { |
154 | $.timeEntry._changeSpinner(inst, input.nextSibling, (disable ? 5 : -1)); |
155 | } |
156 | $.timeEntry._disabledInputs = $.map($.timeEntry._disabledInputs, |
157 | function(value) { return (value == input ? null : value); }); // Delete entry |
158 | if (disable) { |
159 | $.timeEntry._disabledInputs.push(input); |
160 | } |
161 | }, |
162 | |
163 | /* Check whether an input field has been disabled. |
164 | @param input (element) input field to check |
165 | @return (boolean) true if this field has been disabled, false if it is enabled */ |
166 | _isDisabledTimeEntry: function(input) { |
167 | return $.inArray(input, this._disabledInputs) > -1; |
168 | }, |
169 | |
170 | /* Reconfigure the settings for a time entry field. |
171 | @param input (element) input field to change |
172 | @param options (object) new settings to add or |
173 | (string) an individual setting name |
174 | @param value (any) the individual setting's value */ |
175 | _changeTimeEntry: function(input, options, value) { |
176 | var inst = $.data(input, PROP_NAME); |
177 | if (inst) { |
178 | if (typeof options == 'string') { |
179 | var name = options; |
180 | options = {}; |
181 | options[name] = value; |
182 | } |
183 | var currentTime = this._extractTime(inst); |
184 | extendRemove(inst.options, options || {}); |
185 | if (currentTime) { |
186 | this._setTime(inst, new Date(0, 0, 0, |
187 | currentTime[0], currentTime[1], currentTime[2])); |
188 | } |
189 | } |
190 | $.data(input, PROP_NAME, inst); |
191 | }, |
192 | |
193 | /* Remove the time entry functionality from an input. |
194 | @param input (element) input field to affect */ |
195 | _destroyTimeEntry: function(input) { |
196 | $input = $(input); |
197 | if (!$input.hasClass(this.markerClassName)) { |
198 | return; |
199 | } |
200 | $input.removeClass(this.markerClassName).unbind('.timeEntry'); |
201 | if ($.fn.mousewheel) { |
202 | $input.unmousewheel(); |
203 | } |
204 | this._disabledInputs = $.map(this._disabledInputs, |
205 | function(value) { return (value == input ? null : value); }); // Delete entry |
206 | $input.parent().replaceWith($input); |
207 | $.removeData(input, PROP_NAME); |
208 | }, |
209 | |
210 | /* Initialise the current time for a time entry input field. |
211 | @param input (element) input field to update |
212 | @param time (Date) the new time (year/month/day ignored) or null for now */ |
213 | _setTimeTimeEntry: function(input, time) { |
214 | var inst = $.data(input, PROP_NAME); |
215 | if (inst) { |
216 | if (time === null || time === '') { |
217 | inst.input.val(''); |
218 | } |
219 | else { |
220 | this._setTime(inst, time ? (typeof time == 'object' ? |
221 | new Date(time.getTime()) : time) : null); |
222 | } |
223 | } |
224 | }, |
225 | |
226 | /* Retrieve the current time for a time entry input field. |
227 | @param input (element) input field to examine |
228 | @return (Date) current time (year/month/day zero) or null if none */ |
229 | _getTimeTimeEntry: function(input) { |
230 | var inst = $.data(input, PROP_NAME); |
231 | var currentTime = (inst ? this._extractTime(inst) : null); |
232 | return (!currentTime ? null : |
233 | new Date(0, 0, 0, currentTime[0], currentTime[1], currentTime[2])); |
234 | }, |
235 | |
236 | /* Retrieve the millisecond offset for the current time. |
237 | @param input (element) input field to examine |
238 | @return (number) the time as milliseconds offset or zero if none */ |
239 | _getOffsetTimeEntry: function(input) { |
240 | var inst = $.data(input, PROP_NAME); |
241 | var currentTime = (inst ? this._extractTime(inst) : null); |
242 | return (!currentTime ? 0 : |
243 | (currentTime[0] * 3600 + currentTime[1] * 60 + currentTime[2]) * 1000); |
244 | }, |
245 | |
246 | /* Initialise time entry. |
247 | @param target (element) the input field or |
248 | (event) the focus event */ |
249 | _doFocus: function(target) { |
250 | var input = (target.nodeName && target.nodeName.toLowerCase() == 'input' ? target : this); |
251 | if ($.timeEntry._lastInput == input || $.timeEntry._isDisabledTimeEntry(input)) { |
252 | $.timeEntry._focussed = false; |
253 | return; |
254 | } |
255 | var inst = $.data(input, PROP_NAME); |
256 | $.timeEntry._focussed = true; |
257 | $.timeEntry._lastInput = input; |
258 | $.timeEntry._blurredInput = null; |
259 | var beforeShow = $.timeEntry._get(inst, 'beforeShow'); |
260 | extendRemove(inst.options, (beforeShow ? beforeShow.apply(input, [input]) : {})); |
261 | $.data(input, PROP_NAME, inst); |
262 | $.timeEntry._parseTime(inst); |
263 | setTimeout(function() { $.timeEntry._showField(inst); }, 10); |
264 | }, |
265 | |
266 | /* Note that the field has been exited. |
267 | @param event (event) the blur event */ |
268 | _doBlur: function(event) { |
269 | $.timeEntry._blurredInput = $.timeEntry._lastInput; |
270 | $.timeEntry._lastInput = null; |
271 | }, |
272 | |
273 | /* Select appropriate field portion on click, if already in the field. |
274 | @param event (event) the click event */ |
275 | _doClick: function(event) { |
276 | var input = event.target; |
277 | var inst = $.data(input, PROP_NAME); |
278 | if (!$.timeEntry._focussed) { |
279 | var fieldSize = $.timeEntry._get(inst, 'separator').length + 2; |
280 | inst._field = 0; |
281 | if (input.selectionStart != null) { // Use input select range |
282 | for (var field = 0; field <= Math.max(1, inst._secondField, inst._ampmField); field++) { |
283 | var end = (field != inst._ampmField ? (field * fieldSize) + 2 : |
284 | (inst._ampmField * fieldSize) + $.timeEntry._get(inst, 'ampmPrefix').length + |
285 | $.timeEntry._get(inst, 'ampmNames')[0].length); |
286 | inst._field = field; |
287 | if (input.selectionStart < end) { |
288 | break; |
289 | } |
290 | } |
291 | } |
292 | else if (input.createTextRange) { // Check against bounding boxes |
293 | var src = $(event.srcElement); |
294 | var range = input.createTextRange(); |
295 | var convert = function(value) { |
296 | return {thin: 2, medium: 4, thick: 6}[value] || value; |
297 | }; |
298 | var offsetX = event.clientX + document.documentElement.scrollLeft - |
299 | (src.offset().left + parseInt(convert(src.css('border-left-width')), 10)) - |
300 | range.offsetLeft; // Position - left edge - alignment |
301 | for (var field = 0; field <= Math.max(1, inst._secondField, inst._ampmField); field++) { |
302 | var end = (field != inst._ampmField ? (field * fieldSize) + 2 : |
303 | (inst._ampmField * fieldSize) + $.timeEntry._get(inst, 'ampmPrefix').length + |
304 | $.timeEntry._get(inst, 'ampmNames')[0].length); |
305 | range.collapse(); |
306 | range.moveEnd('character', end); |
307 | inst._field = field; |
308 | if (offsetX < range.boundingWidth) { // And compare |
309 | break; |
310 | } |
311 | } |
312 | } |
313 | } |
314 | $.data(input, PROP_NAME, inst); |
315 | $.timeEntry._showField(inst); |
316 | $.timeEntry._focussed = false; |
317 | }, |
318 | |
319 | /* Handle keystrokes in the field. |
320 | @param event (event) the keydown event |
321 | @return (boolean) true to continue, false to stop processing */ |
322 | _doKeyDown: function(event) { |
323 | if (event.keyCode >= 48) { // >= '0' |
324 | return true; |
325 | } |
326 | var inst = $.data(event.target, PROP_NAME); |
327 | switch (event.keyCode) { |
328 | case 9: return (event.shiftKey ? |
329 | // Move to previous time field, or out if at the beginning |
330 | $.timeEntry._changeField(inst, -1, true) : |
331 | // Move to next time field, or out if at the end |
332 | $.timeEntry._changeField(inst, +1, true)); |
333 | case 35: if (event.ctrlKey) { // Clear time on ctrl+end |
334 | $.timeEntry._setValue(inst, ''); |
335 | } |
336 | else { // Last field on end |
337 | inst._field = Math.max(1, inst._secondField, inst._ampmField); |
338 | $.timeEntry._adjustField(inst, 0); |
339 | } |
340 | break; |
341 | case 36: if (event.ctrlKey) { // Current time on ctrl+home |
342 | $.timeEntry._setTime(inst); |
343 | } |
344 | else { // First field on home |
345 | inst._field = 0; |
346 | $.timeEntry._adjustField(inst, 0); |
347 | } |
348 | break; |
349 | case 37: $.timeEntry._changeField(inst, -1, false); break; // Previous field on left |
350 | case 38: $.timeEntry._adjustField(inst, +1); break; // Increment time field on up |
351 | case 39: $.timeEntry._changeField(inst, +1, false); break; // Next field on right |
352 | case 40: $.timeEntry._adjustField(inst, -1); break; // Decrement time field on down |
353 | case 46: $.timeEntry._setValue(inst, ''); break; // Clear time on delete |
354 | } |
355 | return false; |
356 | }, |
357 | |
358 | /* Disallow unwanted characters. |
359 | @param event (event) the keypress event |
360 | @return (boolean) true to continue, false to stop processing */ |
361 | _doKeyPress: function(event) { |
362 | var chr = String.fromCharCode(event.charCode == undefined ? event.keyCode : event.charCode); |
363 | if (chr < ' ') { |
364 | return true; |
365 | } |
366 | var inst = $.data(event.target, PROP_NAME); |
367 | $.timeEntry._handleKeyPress(inst, chr); |
368 | return false; |
369 | }, |
370 | |
371 | /* Increment/decrement on mouse wheel activity. |
372 | @param event (event) the mouse wheel event |
373 | @param delta (number) the amount of change */ |
374 | _doMouseWheel: function(event, delta) { |
375 | if ($.timeEntry._isDisabledTimeEntry(event.target)) { |
376 | return; |
377 | } |
378 | delta = ($.browser.opera ? -delta / Math.abs(delta) : |
379 | ($.browser.safari ? delta / Math.abs(delta) : delta)); |
380 | var inst = $.data(event.target, PROP_NAME); |
381 | inst.input.focus(); |
382 | if (!inst.input.val()) { |
383 | $.timeEntry._parseTime(inst); |
384 | } |
385 | $.timeEntry._adjustField(inst, delta); |
386 | event.preventDefault(); |
387 | }, |
388 | |
389 | /* Expand the spinner, if possible, to make it easier to use. |
390 | @param event (event) the mouse over event */ |
391 | _expandSpinner: function(event) { |
392 | var spinner = $.timeEntry._getSpinnerTarget(event); |
393 | var inst = $.data($.timeEntry._getInput(spinner), PROP_NAME); |
394 | if ($.timeEntry._isDisabledTimeEntry(inst.input[0])) { |
395 | return; |
396 | } |
397 | var spinnerBigImage = $.timeEntry._get(inst, 'spinnerBigImage'); |
398 | if (spinnerBigImage) { |
399 | inst._expanded = true; |
400 | var offset = $(spinner).offset(); |
401 | var relative = null; |
402 | $(spinner).parents().each(function() { |
403 | var parent = $(this); |
404 | if (parent.css('position') == 'relative' || |
405 | parent.css('position') == 'absolute') { |
406 | relative = parent.offset(); |
407 | } |
408 | return !relative; |
409 | }); |
410 | var spinnerSize = $.timeEntry._get(inst, 'spinnerSize'); |
411 | var spinnerBigSize = $.timeEntry._get(inst, 'spinnerBigSize'); |
412 | $('<div class="timeEntry_expand" style="position: absolute; left: ' + |
413 | (offset.left - (spinnerBigSize[0] - spinnerSize[0]) / 2 - |
414 | (relative ? relative.left : 0)) + 'px; top: ' + (offset.top - |
415 | (spinnerBigSize[1] - spinnerSize[1]) / 2 - (relative ? relative.top : 0)) + |
416 | 'px; width: ' + spinnerBigSize[0] + 'px; height: ' + |
417 | spinnerBigSize[1] + 'px; background: transparent url(' + |
418 | spinnerBigImage + ') no-repeat 0px 0px; z-index: 10;"></div>'). |
419 | mousedown($.timeEntry._handleSpinner).mouseup($.timeEntry._endSpinner). |
420 | mouseout($.timeEntry._endExpand).mousemove($.timeEntry._describeSpinner). |
421 | insertAfter(spinner); |
422 | } |
423 | }, |
424 | |
425 | /* Locate the actual input field from the spinner. |
426 | @param spinner (element) the current spinner |
427 | @return (element) the corresponding input */ |
428 | _getInput: function(spinner) { |
429 | return $(spinner).siblings('.' + $.timeEntry.markerClassName)[0]; |
430 | }, |
431 | |
432 | /* Change the title based on position within the spinner. |
433 | @param event (event) the mouse move event */ |
434 | _describeSpinner: function(event) { |
435 | var spinner = $.timeEntry._getSpinnerTarget(event); |
436 | var inst = $.data($.timeEntry._getInput(spinner), PROP_NAME); |
437 | spinner.title = $.timeEntry._get(inst, 'spinnerTexts') |
438 | [$.timeEntry._getSpinnerRegion(inst, event)]; |
439 | }, |
440 | |
441 | /* Handle a click on the spinner. |
442 | @param event (event) the mouse click event */ |
443 | _handleSpinner: function(event) { |
444 | var spinner = $.timeEntry._getSpinnerTarget(event); |
445 | var input = $.timeEntry._getInput(spinner); |
446 | if ($.timeEntry._isDisabledTimeEntry(input)) { |
447 | return; |
448 | } |
449 | if (input == $.timeEntry._blurredInput) { |
450 | $.timeEntry._lastInput = input; |
451 | $.timeEntry._blurredInput = null; |
452 | } |
453 | var inst = $.data(input, PROP_NAME); |
454 | $.timeEntry._doFocus(input); |
455 | var region = $.timeEntry._getSpinnerRegion(inst, event); |
456 | $.timeEntry._changeSpinner(inst, spinner, region); |
457 | $.timeEntry._actionSpinner(inst, region); |
458 | $.timeEntry._timer = null; |
459 | $.timeEntry._handlingSpinner = true; |
460 | var spinnerRepeat = $.timeEntry._get(inst, 'spinnerRepeat'); |
461 | if (region >= 3 && spinnerRepeat[0]) { // Repeat increment/decrement |
462 | $.timeEntry._timer = setTimeout( |
463 | function() { $.timeEntry._repeatSpinner(inst, region); }, |
464 | spinnerRepeat[0]); |
465 | $(spinner).one('mouseout', $.timeEntry._releaseSpinner). |
466 | one('mouseup', $.timeEntry._releaseSpinner); |
467 | } |
468 | }, |
469 | |
470 | /* Action a click on the spinner. |
471 | @param inst (object) the instance settings |
472 | @param region (number) the spinner "button" */ |
473 | _actionSpinner: function(inst, region) { |
474 | if (!inst.input.val()) { |
475 | $.timeEntry._parseTime(inst); |
476 | } |
477 | switch (region) { |
478 | case 0: this._setTime(inst); break; |
479 | case 1: this._changeField(inst, -1, false); break; |
480 | case 2: this._changeField(inst, +1, false); break; |
481 | case 3: this._adjustField(inst, +1); break; |
482 | case 4: this._adjustField(inst, -1); break; |
483 | } |
484 | }, |
485 | |
486 | /* Repeat a click on the spinner. |
487 | @param inst (object) the instance settings |
488 | @param region (number) the spinner "button" */ |
489 | _repeatSpinner: function(inst, region) { |
490 | if (!$.timeEntry._timer) { |
491 | return; |
492 | } |
493 | $.timeEntry._lastInput = $.timeEntry._blurredInput; |
494 | this._actionSpinner(inst, region); |
495 | this._timer = setTimeout( |
496 | function() { $.timeEntry._repeatSpinner(inst, region); }, |
497 | this._get(inst, 'spinnerRepeat')[1]); |
498 | }, |
499 | |
500 | /* Stop a spinner repeat. |
501 | @param event (event) the mouse event */ |
502 | _releaseSpinner: function(event) { |
503 | clearTimeout($.timeEntry._timer); |
504 | $.timeEntry._timer = null; |
505 | }, |
506 | |
507 | /* Tidy up after an expanded spinner. |
508 | @param event (event) the mouse event */ |
509 | _endExpand: function(event) { |
510 | $.timeEntry._timer = null; |
511 | var spinner = $.timeEntry._getSpinnerTarget(event); |
512 | var input = $.timeEntry._getInput(spinner); |
513 | var inst = $.data(input, PROP_NAME); |
514 | $(spinner).remove(); |
515 | inst._expanded = false; |
516 | }, |
517 | |
518 | /* Tidy up after a spinner click. |
519 | @param event (event) the mouse event */ |
520 | _endSpinner: function(event) { |
521 | $.timeEntry._timer = null; |
522 | var spinner = $.timeEntry._getSpinnerTarget(event); |
523 | var input = $.timeEntry._getInput(spinner); |
524 | var inst = $.data(input, PROP_NAME); |
525 | if (!$.timeEntry._isDisabledTimeEntry(input)) { |
526 | $.timeEntry._changeSpinner(inst, spinner, -1); |
527 | } |
528 | if ($.timeEntry._handlingSpinner) { |
529 | $.timeEntry._lastInput = $.timeEntry._blurredInput; |
530 | } |
531 | if ($.timeEntry._lastInput && $.timeEntry._handlingSpinner) { |
532 | $.timeEntry._showField(inst); |
533 | } |
534 | $.timeEntry._handlingSpinner = false; |
535 | }, |
536 | |
537 | /* Retrieve the spinner from the event. |
538 | @param event (event) the mouse click event |
539 | @return (element) the target field */ |
540 | _getSpinnerTarget: function(event) { |
541 | return event.target || event.srcElement; |
542 | }, |
543 | |
544 | /* Determine which "button" within the spinner was clicked. |
545 | @param inst (object) the instance settings |
546 | @param event (event) the mouse event |
547 | @return (number) the spinner "button" number */ |
548 | _getSpinnerRegion: function(inst, event) { |
549 | var spinner = this._getSpinnerTarget(event); |
550 | var pos = ($.browser.opera || $.browser.safari ? |
551 | $.timeEntry._findPos(spinner) : $(spinner).offset()); |
552 | var scrolled = ($.browser.safari ? $.timeEntry._findScroll(spinner) : |
553 | [document.documentElement.scrollLeft || document.body.scrollLeft, |
554 | document.documentElement.scrollTop || document.body.scrollTop]); |
555 | var spinnerIncDecOnly = this._get(inst, 'spinnerIncDecOnly'); |
556 | var left = (spinnerIncDecOnly ? 99 : event.clientX + scrolled[0] - |
557 | pos.left - ($.browser.msie ? 2 : 0)); |
558 | var top = event.clientY + scrolled[1] - pos.top - ($.browser.msie ? 2 : 0); |
559 | var spinnerSize = this._get(inst, (inst._expanded ? 'spinnerBigSize' : 'spinnerSize')); |
560 | var right = (spinnerIncDecOnly ? 99 : spinnerSize[0] - 1 - left); |
561 | var bottom = spinnerSize[1] - 1 - top; |
562 | if (spinnerSize[2] > 0 && Math.abs(left - right) <= spinnerSize[2] && |
563 | Math.abs(top - bottom) <= spinnerSize[2]) { |
564 | return 0; // Centre button |
565 | } |
566 | var min = Math.min(left, top, right, bottom); |
567 | return (min == left ? 1 : (min == right ? 2 : (min == top ? 3 : 4))); // Nearest edge |
568 | }, |
569 | |
570 | /* Change the spinner image depending on button clicked. |
571 | @param inst (object) the instance settings |
572 | @param spinner (element) the spinner control |
573 | @param region (number) the spinner "button" */ |
574 | _changeSpinner: function(inst, spinner, region) { |
575 | $(spinner).css('background-position', '-' + ((region + 1) * |
576 | this._get(inst, (inst._expanded ? 'spinnerBigSize' : 'spinnerSize'))[0]) + 'px 0px'); |
577 | }, |
578 | |
579 | /* Find an object's position on the screen. |
580 | @param obj (element) the control |
581 | @return (object) position as .left and .top */ |
582 | _findPos: function(obj) { |
583 | var curLeft = curTop = 0; |
584 | if (obj.offsetParent) { |
585 | curLeft = obj.offsetLeft; |
586 | curTop = obj.offsetTop; |
587 | while (obj = obj.offsetParent) { |
588 | var origCurLeft = curLeft; |
589 | curLeft += obj.offsetLeft; |
590 | if (curLeft < 0) { |
591 | curLeft = origCurLeft; |
592 | } |
593 | curTop += obj.offsetTop; |
594 | } |
595 | } |
596 | return {left: curLeft, top: curTop}; |
597 | }, |
598 | |
599 | /* Find an object's scroll offset on the screen. |
600 | @param obj (element) the control |
601 | @return (number[]) offset as [left, top] */ |
602 | _findScroll: function(obj) { |
603 | var isFixed = false; |
604 | $(obj).parents().each(function() { |
605 | isFixed |= $(this).css('position') == 'fixed'; |
606 | }); |
607 | if (isFixed) { |
608 | return [0, 0]; |
609 | } |
610 | var scrollLeft = obj.scrollLeft; |
611 | var scrollTop = obj.scrollTop; |
612 | while (obj = obj.parentNode) { |
613 | scrollLeft += obj.scrollLeft || 0; |
614 | scrollTop += obj.scrollTop || 0; |
615 | } |
616 | return [scrollLeft, scrollTop]; |
617 | }, |
618 | |
619 | /* Get a setting value, defaulting if necessary. |
620 | @param inst (object) the instance settings |
621 | @param name (string) the setting name |
622 | @return (any) the setting value */ |
623 | _get: function(inst, name) { |
624 | return (inst.options[name] != null ? |
625 | inst.options[name] : $.timeEntry._defaults[name]); |
626 | }, |
627 | |
628 | /* Extract the time value from the input field, or default to now. |
629 | @param inst (object) the instance settings */ |
630 | _parseTime: function(inst) { |
631 | var currentTime = this._extractTime(inst); |
632 | var showSeconds = this._get(inst, 'showSeconds'); |
633 | if (currentTime) { |
634 | inst._selectedHour = currentTime[0]; |
635 | inst._selectedMinute = currentTime[1]; |
636 | inst._selectedSecond = currentTime[2]; |
637 | } |
638 | else { |
639 | var now = this._constrainTime(inst); |
640 | inst._selectedHour = now[0]; |
641 | inst._selectedMinute = now[1]; |
642 | inst._selectedSecond = (showSeconds ? now[2] : 0); |
643 | } |
644 | inst._secondField = (showSeconds ? 2 : -1); |
645 | inst._ampmField = (this._get(inst, 'show24Hours') ? -1 : (showSeconds ? 3 : 2)); |
646 | inst._lastChr = ''; |
647 | inst._field = Math.max(0, Math.min( |
648 | Math.max(1, inst._secondField, inst._ampmField), this._get(inst, 'initialField'))); |
649 | if (inst.input.val() != '') { |
650 | this._showTime(inst); |
651 | } |
652 | }, |
653 | |
654 | /* Extract the time value from a string as an array of values, or default to null. |
655 | @param inst (object) the instance settings |
656 | @param value (string) the time value to parse |
657 | @return (number[3]) the time components (hours, minutes, seconds) |
658 | or null if no value */ |
659 | _extractTime: function(inst, value) { |
660 | value = value || inst.input.val(); |
661 | var separator = this._get(inst, 'separator'); |
662 | var currentTime = value.split(separator); |
663 | if (separator == '' && value != '') { |
664 | currentTime[0] = value.substring(0, 2); |
665 | currentTime[1] = value.substring(2, 4); |
666 | currentTime[2] = value.substring(4, 6); |
667 | } |
668 | var ampmNames = this._get(inst, 'ampmNames'); |
669 | var show24Hours = this._get(inst, 'show24Hours'); |
670 | if (currentTime.length >= 2) { |
671 | var isAM = !show24Hours && (value.indexOf(ampmNames[0]) > -1); |
672 | var isPM = !show24Hours && (value.indexOf(ampmNames[1]) > -1); |
673 | var hour = parseInt(currentTime[0], 10); |
674 | hour = (isNaN(hour) ? 0 : hour); |
675 | hour = ((isAM || isPM) && hour == 12 ? 0 : hour) + (isPM ? 12 : 0); |
676 | var minute = parseInt(currentTime[1], 10); |
677 | minute = (isNaN(minute) ? 0 : minute); |
678 | var second = (currentTime.length >= 3 ? |
679 | parseInt(currentTime[2], 10) : 0); |
680 | second = (isNaN(second) || !this._get(inst, 'showSeconds') ? 0 : second); |
681 | return this._constrainTime(inst, [hour, minute, second]); |
682 | } |
683 | return null; |
684 | }, |
685 | |
686 | /* Constrain the given/current time to the time steps. |
687 | @param inst (object) the instance settings |
688 | @param fields (number[3]) the current time components (hours, minutes, seconds) |
689 | @return (number[3]) the constrained time components (hours, minutes, seconds) */ |
690 | _constrainTime: function(inst, fields) { |
691 | var specified = (fields != null); |
692 | if (!specified) { |
693 | var now = this._determineTime(inst, this._get(inst, 'defaultTime')) || new Date(); |
694 | fields = [now.getHours(), now.getMinutes(), now.getSeconds()]; |
695 | } |
696 | var reset = false; |
697 | var timeSteps = this._get(inst, 'timeSteps'); |
698 | for (var i = 0; i < timeSteps.length; i++) { |
699 | if (reset) { |
700 | fields[i] = 0; |
701 | } |
702 | else if (timeSteps[i] > 1) { |
703 | fields[i] = Math.round(fields[i] / timeSteps[i]) * timeSteps[i]; |
704 | reset = true; |
705 | } |
706 | } |
707 | return fields; |
708 | }, |
709 | |
710 | /* Set the selected time into the input field. |
711 | @param inst (object) the instance settings */ |
712 | _showTime: function(inst) { |
713 | var show24Hours = this._get(inst, 'show24Hours'); |
714 | var separator = this._get(inst, 'separator'); |
715 | var currentTime = (this._formatNumber(show24Hours ? inst._selectedHour : |
716 | ((inst._selectedHour + 11) % 12) + 1) + separator + |
717 | this._formatNumber(inst._selectedMinute) + |
718 | (this._get(inst, 'showSeconds') ? separator + |
719 | this._formatNumber(inst._selectedSecond) : '') + |
720 | (show24Hours ? '' : this._get(inst, 'ampmPrefix') + |
721 | this._get(inst, 'ampmNames')[(inst._selectedHour < 12 ? 0 : 1)])); |
722 | this._setValue(inst, currentTime); |
723 | this._showField(inst); |
724 | }, |
725 | |
726 | /* Highlight the current time field. |
727 | @param inst (object) the instance settings */ |
728 | _showField: function(inst) { |
729 | var input = inst.input[0]; |
730 | if (inst.input.is(':hidden') || $.timeEntry._lastInput != input) { |
731 | return; |
732 | } |
733 | var separator = this._get(inst, 'separator'); |
734 | var fieldSize = separator.length + 2; |
735 | var start = (inst._field != inst._ampmField ? (inst._field * fieldSize) : |
736 | (inst._ampmField * fieldSize) - separator.length + this._get(inst, 'ampmPrefix').length); |
737 | var end = start + (inst._field != inst._ampmField ? 2 : this._get(inst, 'ampmNames')[0].length); |
738 | if (input.setSelectionRange) { // Mozilla |
739 | input.setSelectionRange(start, end); |
740 | } |
741 | else if (input.createTextRange) { // IE |
742 | var range = input.createTextRange(); |
743 | range.moveStart('character', start); |
744 | range.moveEnd('character', end - inst.input.val().length); |
745 | range.select(); |
746 | } |
747 | if (!input.disabled) { |
748 | input.focus(); |
749 | } |
750 | }, |
751 | |
752 | /* Ensure displayed single number has a leading zero. |
753 | @param value (number) current value |
754 | @return (string) number with at least two digits */ |
755 | _formatNumber: function(value) { |
756 | return (value < 10 ? '0' : '') + value; |
757 | }, |
758 | |
759 | /* Update the input field and notify listeners. |
760 | @param inst (object) the instance settings |
761 | @param value (string) the new value */ |
762 | _setValue: function(inst, value) { |
763 | if (value != inst.input.val()) { |
764 | inst.input.val(value).trigger('change'); |
765 | } |
766 | }, |
767 | |
768 | /* Move to previous/next field, or out of field altogether if appropriate. |
769 | @param inst (object) the instance settings |
770 | @param offset (number) the direction of change (-1, +1) |
771 | @param moveOut (boolean) true if can move out of the field |
772 | @return (boolean) true if exitting the field, false if not */ |
773 | _changeField: function(inst, offset, moveOut) { |
774 | var atFirstLast = (inst.input.val() == '' || inst._field == |
775 | (offset == -1 ? 0 : Math.max(1, inst._secondField, inst._ampmField))); |
776 | if (!atFirstLast) { |
777 | inst._field += offset; |
778 | } |
779 | this._showField(inst); |
780 | inst._lastChr = ''; |
781 | $.data(inst.input[0], PROP_NAME, inst); |
782 | return (atFirstLast && moveOut); |
783 | }, |
784 | |
785 | /* Update the current field in the direction indicated. |
786 | @param inst (object) the instance settings |
787 | @param offset (number) the amount to change by */ |
788 | _adjustField: function(inst, offset) { |
789 | if (inst.input.val() == '') { |
790 | offset = 0; |
791 | } |
792 | var timeSteps = this._get(inst, 'timeSteps'); |
793 | this._setTime(inst, new Date(0, 0, 0, |
794 | inst._selectedHour + (inst._field == 0 ? offset * timeSteps[0] : 0) + |
795 | (inst._field == inst._ampmField ? offset * 12 : 0), |
796 | inst._selectedMinute + (inst._field == 1 ? offset * timeSteps[1] : 0), |
797 | inst._selectedSecond + (inst._field == inst._secondField ? offset * timeSteps[2] : 0))); |
798 | }, |
799 | |
800 | /* Check against minimum/maximum and display time. |
801 | @param inst (object) the instance settings |
802 | @param time (Date) an actual time or |
803 | (number) offset in seconds from now or |
804 | (string) units and periods of offsets from now */ |
805 | _setTime: function(inst, time) { |
806 | time = this._determineTime(inst, time); |
807 | var fields = this._constrainTime(inst, time ? |
808 | [time.getHours(), time.getMinutes(), time.getSeconds()] : null); |
809 | time = new Date(0, 0, 0, fields[0], fields[1], fields[2]); |
810 | // Normalise to base date |
811 | var time = this._normaliseTime(time); |
812 | var minTime = this._normaliseTime(this._determineTime(inst, this._get(inst, 'minTime'))); |
813 | var maxTime = this._normaliseTime(this._determineTime(inst, this._get(inst, 'maxTime'))); |
814 | // Ensure it is within the bounds set |
815 | time = (minTime && time < minTime ? minTime : |
816 | (maxTime && time > maxTime ? maxTime : time)); |
817 | var beforeSetTime = this._get(inst, 'beforeSetTime'); |
818 | // Perform further restrictions if required |
819 | if (beforeSetTime) { |
820 | time = beforeSetTime.apply(inst.input[0], |
821 | [this._getTimeTimeEntry(inst.input[0]), time, minTime, maxTime]); |
822 | } |
823 | inst._selectedHour = time.getHours(); |
824 | inst._selectedMinute = time.getMinutes(); |
825 | inst._selectedSecond = time.getSeconds(); |
826 | this._showTime(inst); |
827 | $.data(inst.input[0], PROP_NAME, inst); |
828 | }, |
829 | |
830 | /* Normalise time object to a common date. |
831 | @param time (Date) the original time |
832 | @return (Date) the normalised time */ |
833 | _normaliseTime: function(time) { |
834 | if (!time) { |
835 | return null; |
836 | } |
837 | time.setFullYear(1900); |
838 | time.setMonth(0); |
839 | time.setDate(0); |
840 | return time; |
841 | }, |
842 | |
843 | /* A time may be specified as an exact value or a relative one. |
844 | @param inst (object) the instance settings |
845 | @param setting (Date) an actual time or |
846 | (number) offset in seconds from now or |
847 | (string) units and periods of offsets from now |
848 | @return (Date) the calculated time */ |
849 | _determineTime: function(inst, setting) { |
850 | var offsetNumeric = function(offset) { // E.g. +300, -2 |
851 | var time = new Date(); |
852 | time.setTime(time.getTime() + offset * 1000); |
853 | return time; |
854 | }; |
855 | var offsetString = function(offset) { // E.g. '+2m', '-4h', '+3h +30m' or '12:34:56PM' |
856 | var fields = $.timeEntry._extractTime(inst, offset); // Actual time? |
857 | var time = new Date(); |
858 | var hour = (fields ? fields[0] : time.getHours()); |
859 | var minute = (fields ? fields[1] : time.getMinutes()); |
860 | var second = (fields ? fields[2] : time.getSeconds()); |
861 | if (!fields) { |
862 | var pattern = /([+-]?[0-9]+)\s*(s|S|m|M|h|H)?/g; |
863 | var matches = pattern.exec(offset); |
864 | while (matches) { |
865 | switch (matches[2] || 's') { |
866 | case 's' : case 'S' : |
867 | second += parseInt(matches[1], 10); break; |
868 | case 'm' : case 'M' : |
869 | minute += parseInt(matches[1], 10); break; |
870 | case 'h' : case 'H' : |
871 | hour += parseInt(matches[1], 10); break; |
872 | } |
873 | matches = pattern.exec(offset); |
874 | } |
875 | } |
876 | time = new Date(0, 0, 10, hour, minute, second, 0); |
877 | if (/^!/.test(offset)) { // No wrapping |
878 | if (time.getDate() > 10) { |
879 | time = new Date(0, 0, 10, 23, 59, 59); |
880 | } |
881 | else if (time.getDate() < 10) { |
882 | time = new Date(0, 0, 10, 0, 0, 0); |
883 | } |
884 | } |
885 | return time; |
886 | }; |
887 | return (setting ? (typeof setting == 'string' ? offsetString(setting) : |
888 | (typeof setting == 'number' ? offsetNumeric(setting) : setting)) : null); |
889 | }, |
890 | |
891 | /* Update time based on keystroke entered. |
892 | @param inst (object) the instance settings |
893 | @param chr (ch) the new character */ |
894 | _handleKeyPress: function(inst, chr) { |
895 | if (chr == this._get(inst, 'separator')) { |
896 | this._changeField(inst, +1, false); |
897 | } |
898 | else if (chr >= '0' && chr <= '9') { // Allow direct entry of time |
899 | var key = parseInt(chr, 10); |
900 | var value = parseInt(inst._lastChr + chr, 10); |
901 | var show24Hours = this._get(inst, 'show24Hours'); |
902 | var hour = (inst._field != 0 ? inst._selectedHour : |
903 | (show24Hours ? (value < 24 ? value : key) : |
904 | (value >= 1 && value <= 12 ? value : |
905 | (key > 0 ? key : inst._selectedHour)) % 12 + |
906 | (inst._selectedHour >= 12 ? 12 : 0))); |
907 | var minute = (inst._field != 1 ? inst._selectedMinute : |
908 | (value < 60 ? value : key)); |
909 | var second = (inst._field != inst._secondField ? inst._selectedSecond : |
910 | (value < 60 ? value : key)); |
911 | var fields = this._constrainTime(inst, [hour, minute, second]); |
912 | this._setTime(inst, new Date(0, 0, 0, fields[0], fields[1], fields[2])); |
913 | inst._lastChr = chr; |
914 | } |
915 | else if (!this._get(inst, 'show24Hours')) { // Set am/pm based on first char of names |
916 | chr = chr.toLowerCase(); |
917 | var ampmNames = this._get(inst, 'ampmNames'); |
918 | if ((chr == ampmNames[0].substring(0, 1).toLowerCase() && |
919 | inst._selectedHour >= 12) || |
920 | (chr == ampmNames[1].substring(0, 1).toLowerCase() && |
921 | inst._selectedHour < 12)) { |
922 | var saveField = inst._field; |
923 | inst._field = inst._ampmField; |
924 | this._adjustField(inst, +1); |
925 | inst._field = saveField; |
926 | this._showField(inst); |
927 | } |
928 | } |
929 | } |
930 | }); |
931 | |
932 | /* jQuery extend now ignores nulls! |
933 | @param target (object) the object to update |
934 | @param props (object) the new settings |
935 | @return (object) the updated object */ |
936 | function extendRemove(target, props) { |
937 | $.extend(target, props); |
938 | for (var name in props) { |
939 | if (props[name] == null) { |
940 | target[name] = null; |
941 | } |
942 | } |
943 | return target; |
944 | } |
945 | |
946 | // Commands that don't return a jQuery object |
947 | var getters = ['getOffset', 'getTime', 'isDisabled']; |
948 | |
949 | /* Attach the time entry functionality to a jQuery selection. |
950 | @param command (string) the command to run (optional, default 'attach') |
951 | @param options (object) the new settings to use for these countdown instances (optional) |
952 | @return (jQuery) for chaining further calls */ |
953 | $.fn.timeEntry = function(options) { |
954 | var otherArgs = Array.prototype.slice.call(arguments, 1); |
955 | if (typeof options == 'string' && $.inArray(options, getters) > -1) { |
956 | return $.timeEntry['_' + options + 'TimeEntry'].apply($.timeEntry, [this[0]].concat(otherArgs)); |
957 | } |
958 | return this.each(function() { |
959 | var nodeName = this.nodeName.toLowerCase(); |
960 | if (nodeName == 'input') { |
961 | if (typeof options == 'string') { |
962 | $.timeEntry['_' + options + 'TimeEntry'].apply($.timeEntry, [this].concat(otherArgs)); |
963 | } |
964 | else { |
965 | // Check for settings on the control itself |
966 | var inlineSettings = ($.fn.metadata ? $(this).metadata() : {}); |
967 | $.timeEntry._connectTimeEntry(this, $.extend(inlineSettings, options)); |
968 | } |
969 | } |
970 | }); |
971 | }; |
972 | |
973 | /* Initialise the time entry functionality. */ |
974 | $.timeEntry = new TimeEntry(); // Singleton instance |
975 | |
976 | })(jQuery); |