/** * Time picker for Bootstrap 4 * * https://github.com/lesilent/timepicker-bs4 */ (function () { //------------------------------------- 'use strict'; /** * Array of dayjs format substrings, * * 0 = used for regex to determine whether format contains unit * 1 = format used for buttons and inputs * 2 = the unit/view name * * @type {array} */ let FORMATS = [ ['h', 'h', 'hour'], ['m', 'mm', 'minute'], ['s', 'ss', 'second'], ['a', 'A', 'meridiem'], ]; /** * Unit lengths * * @type {object} */ const UNIT_LENGTHS = { hour: 24, minute: 60, second: 60, meridiem: 2 }; /** * * @type {string} */ const ACTIVE_CLASS = 'active btn-info'; /** * * @type {string} */ const INACTIVE_CLASS = 'btn-outline-dark border-white'; /** * Flag for whether plugin has been initialized * * @type {boolean} */ let initialized = false; /** * Parse a time string and return a dayjs object * * @param {string} str * @param {object} options * @return {object|boolean} either a dayjs object or false on error */ function parseTime(str, options) { let input_time = false, matches; if (typeof str == 'string') { str = str.replace(/^\s+|\s+$/g, ''); if ((matches = str.match(/^([0-2]?\d)(?:s*:\s*([0-5]\d))?(?:\s*:\s*([0-5]\d))?(?:\s*([AP])\.?(?:M\.?)?)?$/i)) && parseInt(matches[1]) > (matches[4] ? 0 : -1) && parseInt(matches[1]) < (matches[4] ? 13 : 24) && (matches[2] === undefined || (parseInt(matches[2]) > -1 && parseInt(matches[2]) < 60)) && (matches[3] === undefined || (parseInt(matches[3]) > -1 && parseInt(matches[3]) < 60))) { let hour = parseInt(matches[1]); if (matches[4]) { hour = hour % 12 + ((matches[4].toUpperCase() == 'P') ? 12 : 0); } input_time = dayjs().hour(hour).minute(matches[2] == undefined ? 0 : parseInt(matches[2])).second((matches[3] === undefined) ? 0 : parseInt(matches[3])); } else { input_time = (options && options.format) ? dayjs(str, options.format) : dayjs(str); } } else { input_time = dayjs(str); } return (input_time && input_time.isValid()) ? input_time : false; } /** * Return allowed unit text object based on min time, max time, and step * * @param {object} options * @return {object} */ function getUnitText(options) { const minTime = options.minTime || dayjs().startOf('day'); const maxTime = options.maxTime || dayjs().endOf('day'); const step = options.step || 60; let valid = { offset: {}, hour: {}, minute: {}, second: {}, meridiem: {}, length: 0 }; let iTime = minTime.clone(); const unixOffset = minTime.startOf('day').unix(); while (iTime.isBefore(maxTime) || iTime.isSame(maxTime, 'second')) { valid.offset[iTime.unix() - unixOffset] = true; valid.hour[iTime.hour()] = true; valid.minute[iTime.minute()] = true; valid.second[iTime.second()] = true; valid.meridiem[(iTime.hour() > 11) ? 1 : 0] = true; valid.length++; iTime = iTime.add(step, 'second'); } // Convert valid units to arrays let unitText = { hour: [], minute: [], second:[], meridiem: [], length: valid.length }; for (let i = 0; i < 24; i++) { unitText.hour.push((i in valid.hour) ? ((i == 0 || i == 12) ? 12 : i % 12) : null); } ['minute', 'second'].forEach(function (field) { for (let i = 0; i < 60; i++) { unitText[field].push((i in valid[field]) ? ((i < 10) ? '0' + i : i) : null); } }); unitText.meridiem.push((0 in valid.meridiem) ? 'AM' : null); unitText.meridiem.push((1 in valid.meridiem) ? 'PM' : null); /* // Craate position arrays for (let i = 1; i < 13; i++) { position.hour[i] = ((i % 12) in valid.hour || (i % 12 + 12) in valid.hour); position.minute[i] = (i * 5 % 60) in valid.minute; position.second[i] = (i * 5 % 60) in valid.second; position.meridiem[i] = (i % 6 > 0) ? (((i < 6) ? 1 : 0) in valid.meridiem) : false; } */ return unitText; } /** * Return whether format string contains a token string * * This removes escaped characters from format string prior searching for token * * @param {string} format * @param {string} searchElement * @return {boolean} */ function hasFormat(format, searchElement) { return (format.replace(/\[[^\]]*\]/g).indexOf(searchElement) >= 0); } /** * Update the view * * @param {object} $input the input object */ function updateView($input) { const prevView = $input.data('prevview'); const view = $input.data('view') || 'hour'; const viewTime = $input.data('viewtime'); const options = $input.data('options'); const clock_24 = hasFormat(options.format, 'H'); const step = options.step || 60; let submit_disabled = false; if (60 % step > 0) { const minTime = options.minTime || dayjs().startOf('day'); let viewOffset = viewTime.diff(viewTime.startOf('day'), 'second'); if (!hasFormat(options.format, 's')) { viewOffset -= (viewOffset % 60); } submit_disabled = ((viewOffset - minTime.diff(minTime.startOf('day'), 'second')) % step > 0); } const input_id = $input.attr('id'); const $content = jQuery('#' + input_id + '-picker-content').attr('data-view', view); $content.find('.submit-btn').prop('disabled', submit_disabled || (options.minTime && viewTime.isBefore(options.minTime, 'second')) || (options.maxTime && viewTime.isAfter(options.maxTime, 'second'))); let number, position, format; switch (view) { case 'hour': number = viewTime.get(view); position = (number % 12 > 0) ? (number % 12) : 12; format = clock_24 ? 'H' : 'h'; if (hasFormat(options.format, format + format)) { format += format; } break; case 'minute': case 'second': number = viewTime.get(view); if (number % 5 == 0) { position = (number > 0) ? (number / 5) : 12; } format = view.charAt(0).repeat(2); break; case 'meridiem': position = (viewTime.hour() > 11) ? 3 : 9; format = 'A'; break; default: console.warn('Invalid view ' + view); return false; } const text = viewTime.format(format); $content.find('.timepicker-btns button').removeClass('font-weight-bold').filter('[data-unit="' + view + '"]').addClass('font-weight-bold').text(text); $content.find('.clock-input-table .chevron-btn').data('unit', view); FORMATS[0][1] = clock_24 ? 'H' : 'h'; FORMATS.forEach(function (formats) { const text = viewTime.format(formats[1]); $content.find('.' + formats[2] + '-btn').text(text); $content.find('.' + formats[2] + '-input').val(text); }); if (view != prevView) { $content.find('.clock-input-table button').each(function () { const $this = jQuery(this); let pos = parseInt($this.attr('class').match(/\bpos\-(\d+)/)[1]); let disabled = true; if (pos > 0) { let positions; switch (view) { case 'hour': positions = [pos, pos % 12, pos % 12 + 12]; break; case 'minute': case 'second': positions = [pos % 12 * 5]; break; case 'meridiem': positions = (pos % 6 > 0) ? [(pos < 6) ? 1 : 0] : []; break; } positions.forEach(function (pos) { disabled &&= (options.unitText[view][pos] === null); }); $this.prop('disabled', disabled).toggleClass('text-light', disabled).toggleClass(INACTIVE_CLASS, pos != position).toggleClass(ACTIVE_CLASS, pos == position); } else { $this.text(text); } }); } $content.data('prevview', view); return true; } /** * Update the clock picker in the popover * * @param {object} $input the input object */ function updatePicker($input) { const input_id = $input.attr('id'); const options = $input.data('options'); const now = dayjs(); const minTime = options.minTime || dayjs().startOf('day'); const maxTime = options.maxTime || dayjs().endOf('day'); const step = options.step || 60; let validSteps = { hour: {}, minute: {}, second: {}, meridiem: {} }; let viewTime = $input.data('viewtime'); let iTime = minTime.clone(); while (iTime.isBefore(maxTime) || iTime.isSame(maxTime, 'second')) { validSteps.hour[iTime.hour()] = true; validSteps.minute[iTime.minute()] = true; validSteps.second[iTime.second()] = true; validSteps.meridiem[(iTime.hour() > 11) ? 1 : 0] = true; if (!iTime.isBefore(minTime) && !iTime.isAfter(maxTime)) { if (!viewTime && now.isBefore(iTime)) { viewTime = iTime; } } iTime = iTime.add(step, 'second'); } if (!viewTime) { viewTime = now.endOf(hasFormat(options.format, 's') ? 'second' : 'minute'); } $input.data('viewtime', viewTime); // Build html const has_second = (hasFormat(options.format, 's') && step % 60 > 0); const clock_24 = hasFormat(options.format, 'H'); const clock_enabled = ((step % 300) == 0) && false; const viewHour = viewTime.hour(); let html = '
' + '
' + '
' + '' + ':' + '' + (has_second ? ':' : '') + (clock_24 ? '' : '') + '
' + ('' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
HourMinuteSecondMeridiem
{{11}}{{12}}{{1}}
{{10}}{{2}}
{{9}}{{0}}{{3}}
{{8}}{{4}}
{{7}}{{6}}{{5}}
').replace(/{{(\w+)}}/g, function (match, position) { if (position == 0) { return ''; } const pos_hour = position % 12; const pos_minute = pos_hour * 5; const meridiem_class = (position % 3 > 0) ? ' text-light' : ''; const meridiem_text = (position % 6 > 0) ? ((position < 6) ? 'PM' : 'AM') : ' '; return ''; }) + '
' + '' + '' + '' + (has_second ? '' : '') + (clock_24 ? '' : '') + '' + '' + (has_second ? '' : '') + (clock_24 ? '' : '') + '' + '' + '' + '' + (has_second ? '' : '') + (clock_24 ? '' : '') + '
::
' + '
' + '' + '
' + '
'; const $content = jQuery('#' + input_id + '-picker-content'); const $table = $content.html(html).find('.clock-input-table'); const $center_btn = jQuery('#' + input_id + '-picker-center-btn'); $content.parents('.timepicker-popover').attr('data-scheme', (options.scheme == 'auto') ? ((window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : '') : options.scheme); $content.find('.timepicker-btns button').on('click', function () { const unit = jQuery(this).blur().data('unit'); $content.find('.clock-input-table .chevron-btn').data('unit', unit); updateView($input.data('view', unit)); }); const $hour_input = $content.find('.hour-input').on('change', function () { let hour = this.value.replace(/\D+/, ''); if (hour.length > 0) { hour = parseInt(hour); if (hour > -1 && hour < 24 && hour in options.unitText.hour) { $input.data('viewtime', $input.data('viewtime').hour(hour)); } } updateView($input); }); $hour_input.add($content.find('.minute-input, .second-input').on('change', function () { let number = this.value.replace(/\D+/, ''); if (number.length > 0) { number = parseInt(number); const unit = jQuery(this).attr('class').match(/(minute|second)\-input/)[1]; if (number > -1 && number < 60 && number in options.unitText[unit]) { $input.data('viewtime', $input.data('viewtime').set(unit, number)); } } updateView($input); })).on('keyup', function (event) { if (event.key == 'Enter') { $content.find('.submit-btn').triggerHandler('click'); } }); $content.find('.meridiem-btn').on('click', function () { $input.data('viewtime', $input.data('viewtime').hour(($input.data('viewtime').hour() + 12) % 24)); updateView($input); }).on('keydown', function (event) { const key = event.key.toUpperCase(); const hour = $input.data('viewtime').hour(); let offset = 0; if (key == 'A' && hour > 12) { offset = -12; } else if (key == 'P' && hour < 11) { offset = 12; } if (offset != 0) { $input.data('viewtime', $input.data('viewtime').hour(hour + offset)); updateView($input); } }); $content.find('.input-toggle-btn').attr('tabindex', -1).on('click', function () { const input = jQuery(this).data('input'); $content.find('.clock-input').toggleClass('d-none', input != 'clock'); $content.find('.keyboard-input').toggleClass('d-none', input != 'keyboard'); $content.find('.input-toggle-btn').each(function () { const $this = jQuery(this); $this.toggleClass('d-none', $this.data('input') == input); }); $input.popover('update'); $hour_input.select(); }); $content.find('.cancel-btn').on('click', function () { $input.popover('hide'); }); $content.find('.submit-btn').on('click', function () { $input.val($input.data('viewtime').format($input.data('options').format)).popover('hide').data('view', null).trigger('change'); }); $table.find('button').on('click', function () { const $this = jQuery(this); let viewTime = $input.data('viewtime'); let position = $this.attr('class').match(/\bpos\-(\d+)/)[1]; const view = $input.data('view') || 'hour'; switch (view) { case 'hour': if (position > 0) { viewTime = viewTime.hour(position % 12 + ((viewTime.hour() > 11) ? 12 : 0)); } break; case 'minute': if (position > 0) { viewTime = viewTime.minute(position % 12 * 5); } break; case 'second': if (position > 0) { viewTime = viewTime.second(position % 12 * 5); } break; case 'meridiem': if (position > 0) { viewTime = viewTime.hour(viewTime.hour() % 12 + ((position < 6) ? 12 : 0)); } break; } $input.data('viewtime', viewTime); let picked = true; FORMATS.forEach(function (formats) { const regex = new RegExp(formats[0], 'i'); if (picked && regex.test(options.format)) { position = null; let number; const nextUnit = formats[2]; switch (nextUnit) { case 'hour': number = viewTime.get(nextUnit) % 12; position = (number > 0) ? number : 12; break; case 'minute': case 'second': number = viewTime.get(nextUnit); if (number % 5 == 0) { position = (number > 0) ? (number / 5) : 12; } break; case 'meridiem': position = (viewTime.hour() > 11) ? 3 : 9; break; } $input.data('view', nextUnit); picked = false; } }); if (picked) { $input.val(viewTime.format(options.format)).popover('hide').data('view', null).trigger('change'); } else { updateView($input); } }); $content.find('.chevron-btn').attr('tabindex', -1).on('click', function () { const $this = jQuery(this).blur(); const options = $input.data('options'); const unit = $this.data('unit') || 'hour'; const step = $this.data('step'); const viewTime = $input.data('viewtime'); const number = (unit == 'meridiem') ? ((viewTime.hour() > 11) ? 1 : 0) : viewTime.get(unit); if (!(number in options.unitText[unit])) { // Set to number } if (options.unitText[unit].length < 2) { return; } const unitLength = UNIT_LENGTHS[unit]; let idx = number; switch (unit) { case 'hour': case 'minute': case 'second': do { idx = (idx + step + unitLength) % unitLength; if (options.unitText[unit][idx] !== null) { $center_btn.text(options.unitText[unit][idx]); $input.data('viewtime', viewTime.set(unit, idx)); break; } } while (idx != number); break; case 'meridiem': idx = (idx + step + unitLength) % unitLength; if (options.unitText[unit][idx] !== null) { $center_btn.text(options.unitText[unit][idx]); $input.data('viewtime', viewTime.hour(viewTime.hour() % 12 + ((idx > 0) ? 12 : 0))); } break; } updateView($input); }); updateView($input); } /** * Add method for initializing plugin */ jQuery.fn.timepicker = function (options) { // Get boostrap version const bs_version = parseInt(((typeof bootstrap == 'object') ? bootstrap.Dropdown.VERSION : jQuery.fn.dropdown.Constructor.VERSION || '0').replace(/\..+$/, '')); if (bs_version < 4) { console.error('Invalid bootstrap version ' + bs_version + ' detected'); } // Handle functions if (typeof options == 'string') { if (this.length < 1) { return undefined; } let input_options = this.data('options') || {}; const single_arg = (arguments.length == 1); switch (options) { case 'format': if (single_arg) { return input_options.format; } else if (arguments[1] && typeof arguments[1] == 'string') { input_options.format = arguments[1]; this.data('options', input_options); } else { console.warn('Invalid format'); } break; case 'defaultTime': case 'minTime': case 'maxTime': if (single_arg) { return input_options[options]; } else if (arguments[1]) { let newTime = parseTime(arguments[1]); if (newTime && newTime.isValid()) { if (options == 'defaultTime') { if (input_options.minTime && newTime.isBefore(input_options.minTime)) { newTime = false; console.warn('defaultTime is before minTime'); } else if (input_options.maxTime && newTime.isAfter(input_options.maxTime)) { newTime = false; console.warn('defaultTime is after maxTime'); } } if (newTime) { input_options[options] = newTime; input_options.unitText = getUnitText(input_options); this.data('options', input_options); } } else { console.warn('Invalid ' + options); } } else { input_options[options] = null; input_options.unitText = getUnitText(input_options); this.data('options', input_options); } break; case 'step': if (single_arg) { return input_options[options]; } else if (arguments[1]) { const step = parseInt(arguments[1]); if (step > 0 && step < 86400 && step % (hasFormat(input_options.format, 's') ? 1 : 60) == 0) { input_options.step = step; input_options.unitText = getUnitText(input_options); this.data('options', input_options); } else { console.warn('Invalid ' + options); } } else { input_options.step = 60; input_options.unitText = getUnitText(input_options); this.data('options', input_options); } break; case 'scheme': if (single_arg) { return input_options.scheme; } else if (arguments[1] === null || typeof arguments[1] == 'string') { input_options.scheme = arguments[1]; this.data('options', input_options); } else { console.warn('Invalid scheme'); } break; case 'time': if (single_arg) { return parseTime(this.val(), input_options) || null; } else { const newTime = (arguments[1]) ? parseTime(arguments[1], input_options) : null; this.val((newTime && newTime.isValid()) ? newTime.format(input_options.format) : ''); } break; case 'viewTime': if (single_arg) { return this.data('viewtime'); } else { const newTime = (arguments[1]) ? parseTime(arguments[1], input_options) : null; this.data('viewtime', newTime); } break; case 'view': if (single_arg) { return this.data('view'); } else { const view = arguments[1]; updateView(jQuery(this).data('view', view)); } break; default: break; } return this; } // Initialize code if it hasn't already if (!initialized) { initialized = true; let table_class = '.timepicker-table '; jQuery(document.head).append(''); // Make popovers close when clicked outside of them jQuery(document.body).on('mouseup', function (e) { if (jQuery(e.target).parents('.popover').length == 0) { jQuery('.timepicker').popover('hide'); } }); } // Process options if (typeof options == 'undefined') { options = {}; } const common_options = jQuery.extend({}, jQuery.fn.timepicker.defaults, options); ['minTime', 'maxTime'].forEach(function (option) { if (common_options[option]) { common_options[option] = parseTime(common_options[option]); } }); // Initialize the inputs return this.each(function () { const $input = jQuery(this); // Get input id let input_id = this.id; let $toggles = $input.siblings().find('[data-toggle="timepicker"]:not([data-target])'); if (this.id) { $toggles = $toggles.add('[data-toggle="timepicker"][data-target="#' + this.id + '"]'); } else { input_id = 'input-' + Math.floor(Math.random() * 1000000 + 1); this.id = input_id; } // Process options let input_options = jQuery.extend(true, {}, common_options); let format = $input.data('format') || common_options.format; if (format) { input_options.format = format; } let minTime = $input.attr('min') || $input.data('mintime') || common_options.minTime; if (minTime && (minTime = parseTime(minTime)) && minTime.isValid()) { input_options.minTime = minTime; } let maxTime = $input.attr('max') || $input.data('maxtime') || common_options.maxTime; if (maxTime && (maxTime = parseTime(maxTime)) && maxTime.isValid()) { input_options.maxTime = maxTime; } let defaultTime = $input.data('default') || common_options.defaultTime; if (defaultTime && (defaultTime = parseTime(defaultTime)) && defaultTime.isValid() && !(input_options.minTime && defaultTime.isBefore(input_options.minTime)) && !(input_options.maxTime && defaultTime.isAfter(input_options.maxTime))) { input_options.defaultTime = defaultTime; } const step = $input.attr('step') || $input.data('step') || common_options.step; if (step > 0 && step < 86400 && 60 % step > 0) { input_options.step = parseInt(step); } const scheme = $input.data('scheme') || common_options.scheme; if (scheme) { input_options.scheme = scheme; } input_options.unitText = getUnitText(input_options); $input.data('options', input_options); if ($input.data('timepicker')) { // If timepicker is already initialized, then return return this; } $input.data('timepicker', true).addClass('timepicker'); // Set inputmode if (this.type == 'text' && !this.inputMode) { this.inputMode = 'tel'; } const $label = jQuery('label[for="' + input_id + '"]'); const placement = (window.screen.width > 575) ? 'bottom' : 'top'; $input.on('change', function () { this.value = this.value.replace(/^\s+|\s+$/g, ''); const options = $input.data('options'); const newTime = parseTime(this.value, options); this.value = (newTime !== false) ? newTime.format(options.format) : ''; }).on('shown.bs.popover', function () { if (window.screen.width > 575) { jQuery('#' + input_id + '-picker-content').find('.hour-input').select(); } }).on('inserted.bs.popover', function () { jQuery('.popover').find('[data-dismiss="popover"]').on('click', function () { $input.popover('hide'); }); updatePicker($input, input_options); }).on('hide.bs.popover', function () { $input.data('view', null); }).popover({ html: true, placement: placement, sanitize: false, title: '' + (($label.length > 0) ? $label.html() : 'Time'), template: '', trigger: (($toggles.length > 0) ? 'manual' : 'click'), popperConfig: { /* modifiers: { hide: { enabled: false }, preventOverflow: { enabled: false, // boundariesElement: 'window', escapeWithReference: true } }, // positionFixed: true */ }, content: function () { const options = $input.data('options'); const viewTime = parseTime($input.val() || options.defaultTime || '', options); $input.data('viewtime', viewTime); return '
'; } }); $toggles.on('click', function () { $input.popover('toggle'); this.blur(); }); }); }; /** * Default options * * @type {object} * @todo add support for additional options */ jQuery.fn.timepicker.defaults = { defaultTime: null, format: 'hh:mm A', maxTime: null, minTime: null, step: 60, scheme: 'light' }; /* * Initialize timepickers */ document.addEventListener('DOMContentLoaded', function() { jQuery('[data-toggle="timepicker"][data-target]').each(function () { jQuery(jQuery(this).data('target')).timepicker(); }); }); //------------------------------------- }());