UNPKG

datatables.net-datetime

Version:

DateTime picker for DataTables.net

1,963 lines (1,709 loc) 44.2 kB
/*! DateTime for DataTables.net * Copyright (c) SpryMedia Ltd - datatables.net/license */ import DataTable, { Context, Dom, util } from 'datatables.net'; import './interface'; import { Defaults, DomInternal, Settings } from './interface'; // Supported formatting and parsing libraries: // * Moment // * Luxon // * DayJS var dateLib; export class DateTime { /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Statics */ /** * Use a specific compatible date library */ public static use(lib) { dateLib = lib; } /** * For generating unique namespaces */ private static _instance = 0; /** * To indicate to DataTables what type of library this is */ public static type = 'DateTime'; /** * Defaults for the date time picker */ public static defaults: Defaults = { alwaysVisible: false, attachTo: 'body', buttons: { clear: false, selected: false, today: false }, classPrefix: 'dt-datetime', disableDays: null, display: null, firstDay: null, format: 'YYYY-MM-DD', hoursAvailable: null, i18n: { clear: 'Clear', previous: 'Previous', next: 'Next', months: [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ], weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], amPm: ['am', 'pm'], hours: 'Hour', minutes: 'Minute', seconds: 'Second', unknown: '-', today: 'Today', selected: 'Selected' }, maxDate: null, minDate: null, minutesAvailable: null, minutesIncrement: 1, // deprecated strict: true, locale: 'en', onChange: () => {}, secondsAvailable: null, secondsIncrement: 1, // deprecated showWeekNumber: false, yearRange: 25 }; public static version = '2.0.0'; /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Public methods */ /** * Destroy the control */ public destroy() { var namespace = this.s.namespace; clearTimeout(this.s.showTo); this._hide(true); this.dom.container.off().empty(); this.dom.input .classRemove('dt-datetime') .attrRemove('autocomplete') .off('.datetime'); Dom.s(document).off('.' + namespace); Dom.w.off('.' + namespace); } public display(year, month) { if (year !== undefined) { this.s.display.setUTCFullYear(year); } if (month !== undefined) { this.s.display.setUTCMonth(month - 1); } if (year !== undefined || month !== undefined) { this._setTitle(); this._setCalander(); return this; } return this.s.display ? { month: this.s.display.getUTCMonth() + 1, year: this.s.display.getUTCFullYear() } : { month: null, year: null }; } public errorMsg(msg) { var error = this.dom.error; if (msg) { error.html(msg); } else { error.empty(); } return this; } public hide() { this._hide(); return this; } public max(date) { this.c.maxDate = typeof date === 'string' ? new Date(date) : date; this._optionsTitle(); this._setCalander(); return this; } public min(date) { this.c.minDate = typeof date === 'string' ? new Date(date) : date; this._optionsTitle(); this._setCalander(); return this; } /** * Check if an element belongs to this control * * @param {node} node Element to check * @return {boolean} true if owned by this control, false otherwise */ public owns(node) { return Dom.s(node).closest(this.dom.container.get(0)).count() > 0; } /** * Get the value */ public val(): Date; /** * Set the value * * @param set Value to set * @param write Flag to indicate if the formatted value * should be written into the input element */ public val(set: string | Date, write?: boolean); public val(set?: string | Date, write: boolean = true) { if (set === undefined) { return this.s.d; } var oldVal = this.s.d; if (set instanceof Date) { this.s.d = this._dateToUtc(set); } else if (set === null || set === '') { this.s.d = null; } else if (set === '--now') { this.s.d = this._dateToUtc(new Date()); } else if (typeof set === 'string') { this.s.d = this._dateToUtc(this._convert(set, this.c.format, null)); } if (write || write === undefined) { if (this.s.d) { this._writeOutput( false, (oldVal === null && this.s.d !== null) || (oldVal !== null && this.s.d === null) || oldVal.toString() !== this.s.d.toString() ); } else { // The input value was not valid... this.dom.input.val(set as string); } } // Need something to display if (this.s.d) { this.s.display = new Date(this.s.d.toString()); } else if (this.c.display) { this.s.display = new Date(); this.s.display.setUTCDate(1); this.display(this.c.display.year, this.c.display.month); } else { this.s.display = new Date(); } // Set the day of the month to be 1 so changing between months doesn't // run into issues when going from day 31 to 28 (for example) this.s.display.setUTCDate(1); // Update the display elements for the new value this._setTitle(); this._setCalander(); this._setTime(); return this; } /** * Similar to `val()` but uses a given date / time format * * @param format Format to get the data as (getter) or that is input * (setter) * @param val Value to write (if undefined, used as a getter) * @returns */ public valFormat(format, val) { if (!val) { return this._convert(this.val(), null, format); } // Convert from the format given here to the instance's configured // format this.val(this._convert(val, format, null)); return this; } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Private properties */ private dom: DomInternal; private c: Defaults; private s: Settings; /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Constructor */ constructor(input, opts) { // Attempt to auto detect the formatting library (if there is one). // Having it in the constructor allows load order independence. let win = window as any; if (typeof dateLib === 'undefined') { dateLib = win.moment ? win.moment : win.dayjs ? win.dayjs : win.luxon ? win.luxon : null; } this.c = util.object.assignDeep({}, DateTime.defaults, opts); var classPrefix = this.c.classPrefix; // Only IS8601 dates are supported without moment, dayjs or luxon if (!dateLib && this.c.format !== 'YYYY-MM-DD') { throw "DateTime: Without momentjs, dayjs or luxon only the format 'YYYY-MM-DD' can be used"; } if (this._isLuxon() && this.c.format == 'YYYY-MM-DD') { this.c.format = 'yyyy-MM-dd'; } // Min and max need to be `Date` objects in the config if (typeof this.c.minDate === 'string') { this.c.minDate = new Date(this.c.minDate); } if (typeof this.c.maxDate === 'string') { this.c.maxDate = new Date(this.c.maxDate); } // DOM structure var structure = Dom.c('div') .classAdd(classPrefix) .html( '<div class="' + classPrefix + '-date">' + '<div class="' + classPrefix + '-title">' + '<div class="' + classPrefix + '-iconLeft">' + '<button type="button"></button>' + '</div>' + '<div class="' + classPrefix + '-iconRight">' + '<button type="button"></button>' + '</div>' + '<div class="' + classPrefix + '-label">' + '<span></span>' + '<select class="' + classPrefix + '-month"></select>' + '</div>' + '<div class="' + classPrefix + '-label">' + '<span></span>' + '<select class="' + classPrefix + '-year"></select>' + '</div>' + '</div>' + '<div class="' + classPrefix + '-buttons">' + '<a class="' + classPrefix + '-clear"></a>' + '<a class="' + classPrefix + '-today"></a>' + '<a class="' + classPrefix + '-selected"></a>' + '</div>' + '<div class="' + classPrefix + '-calendar"></div>' + '</div>' + '<div class="' + classPrefix + '-time">' + '<div class="' + classPrefix + '-hours"></div>' + '<div class="' + classPrefix + '-minutes"></div>' + '<div class="' + classPrefix + '-seconds"></div>' + '</div>' + '<div class="' + classPrefix + '-error"></div>' ); this.dom = { container: structure, date: structure.find('.' + classPrefix + '-date'), title: structure.find('.' + classPrefix + '-title'), calendar: structure.find('.' + classPrefix + '-calendar'), time: structure.find('.' + classPrefix + '-time'), error: structure.find('.' + classPrefix + '-error'), buttons: structure.find('.' + classPrefix + '-buttons'), clear: structure.find('.' + classPrefix + '-clear'), today: structure.find('.' + classPrefix + '-today'), selected: structure.find('.' + classPrefix + '-selected'), previous: structure.find('.' + classPrefix + '-iconLeft'), next: structure.find('.' + classPrefix + '-iconRight'), input: Dom.s(input) }; this.s = { d: null, display: null, minutesRange: null, secondsRange: null, namespace: 'datetime-' + DateTime._instance++, parts: { date: this.c.format.match(/[yYMDd]|L(?!T)|l/) !== null, time: this.c.format.match(/[Hhm]|LT|LTS/) !== null, seconds: this.c.format.indexOf('s') !== -1, hours12: this.c.format.match(/[haA]/) !== null }, showTo: null }; this.dom.container .append(this.dom.date) .append(this.dom.time) .append(this.dom.error); this.dom.date .append(this.dom.title) .append(this.dom.buttons) .append(this.dom.calendar); this.dom.input.classAdd('dt-datetime'); this._init(); } /** * Build the control and assign initial event handlers */ private _init() { var that = this; var classPrefix = this.c.classPrefix; var last = this.dom.input.val(); var namespace = this.s.namespace; var onChange = function () { var curr = that.dom.input.val(); if (curr !== last) { that.c.onChange.call( that, curr, that.s.d, that.dom.input.get(0) ); last = curr; } }; if (!this.s.parts.date) { this.dom.date.css('display', 'none'); } if (!this.s.parts.time) { this.dom.time.css('display', 'none'); } if (!this.s.parts.seconds) { this.dom.time.children('div.' + classPrefix + '-seconds').remove(); this.dom.time.children('span').eq(1).remove(); } if (!this.c.buttons.clear) { this.dom.clear.css('display', 'none'); } if (!this.c.buttons.today) { this.dom.today.css('display', 'none'); } if (!this.c.buttons.selected) { this.dom.selected.css('display', 'none'); } // Render the options this._optionsTitle(); Dom.s(document).on( 'i18n.dt.' + namespace, function (e, settings: Context) { if (settings.language.datetime) { util.object.assignDeep( that.c.i18n, settings.language.datetime ); that._optionsTitle(); } } ); // When attached to a hidden input, we always show the input picker, and // do so inline if (this.dom.input.attr('type') === 'hidden' || this.c.alwaysVisible) { this.dom.container.classAdd('inline'); this.c.attachTo = 'input'; this.val(this.dom.input.val(), false); this._show(); } // Set the initial value if (last) { this.val(last, false); } // Trigger the display of the widget when clicking or focusing on the // input element this.dom.input .attr('autocomplete', 'off') .on('focus.datetime click.datetime', function () { // If already visible - don't do anything if ( that.dom.container.isVisible() || that.dom.input.is(':disabled') ) { return; } // In case the value has changed by text last = that.dom.input.val(); that.val(last, false); that._show(); }) .on('keyup.datetime', function () { // Update the calendar's displayed value as the user types that.val(that.dom.input.val(), false); }); // Want to prevent the focus bubbling up the document to account for // focus capture in modals (e.g. Editor and Bootstrap). They can see the // focus as outside the modal and thus immediately blur focus on the // picker. Need to use a native addEL since jQuery changes the focusin // to focus for some reason! focusin bubbles, focus does not. this.dom.container.get(0).addEventListener('focusin', function (e) { e.stopPropagation(); }); // Main event handlers for input in the widget this.dom.container .on('change', 'select', function () { var select = Dom.s(this); var val = parseInt(select.val()); if (select.classHas(classPrefix + '-month')) { // Month select that._correctMonth(that.s.display, val); that._setTitle(); that._setCalander(); } else if (select.classHas(classPrefix + '-year')) { // Year select that.s.display.setUTCFullYear(val); that._setTitle(); that._setCalander(); } else if ( select.classHas(classPrefix + '-hours') || select.classHas(classPrefix + '-ampm') ) { // Hours - need to take account of AM/PM input if present if (that.s.parts.hours12) { var hours = parseInt( that.dom.container .find('.' + classPrefix + '-hours') .val(), 10 ); var pm = that.dom.container .find('.' + classPrefix + '-ampm') .val() === 'pm'; that.s.d.setUTCHours( hours === 12 && !pm ? 0 : pm && hours !== 12 ? hours + 12 : hours ); } else { that.s.d.setUTCHours(val); } that._setTime(); that._writeOutput(true); onChange(); } else if (select.classHas(classPrefix + '-minutes')) { // Minutes select that.s.d.setUTCMinutes(val); that._setTime(); that._writeOutput(true); onChange(); } else if (select.classHas(classPrefix + '-seconds')) { // Seconds select that.s.d.setSeconds(val); that._setTime(); that._writeOutput(true); onChange(); } that.dom.input.focus(); that._position(); }) .on('click', function (e) { var d = that.s.d; var nodeName = e.target.nodeName.toLowerCase(); var target = nodeName === 'span' ? e.target.parentNode : e.target; nodeName = target.nodeName.toLowerCase(); if (nodeName === 'select') { return; } e.stopPropagation(); if (nodeName === 'a') { e.preventDefault(); if (Dom.s(target).classHas(classPrefix + '-clear')) { // Clear the value and don't change the display that.s.d = null; that.dom.input.val(''); that._writeOutput(); that._setCalander(); that._setTime(); onChange(); } else if (Dom.s(target).classHas(classPrefix + '-today')) { // Don't change the value, but jump to the month // containing today that.s.display = new Date(); that._setTitle(); that._setCalander(); } else if ( Dom.s(target).classHas(classPrefix + '-selected') ) { // Don't change the value, but jump to where the // selected value is that.s.display = new Date(that.s.d.getTime()); that._setTitle(); that._setCalander(); } } if (nodeName === 'button') { var button = Dom.s(target); var parent = button.parent(); if ( parent.classHas('disabled') && !parent.classHas('range') ) { button.blur(); return; } if (parent.classHas(classPrefix + '-iconLeft')) { // Previous month that.s.display.setUTCMonth( that.s.display.getUTCMonth() - 1 ); that._setTitle(); that._setCalander(); that.dom.input.focus(); } else if (parent.classHas(classPrefix + '-iconRight')) { // Next month that._correctMonth( that.s.display, that.s.display.getUTCMonth() + 1 ); that._setTitle(); that._setCalander(); that.dom.input.focus(); } else if ( button.closest('.' + classPrefix + '-time').count() ) { var val = button.data('value'); var unit = button.data('unit'); d = that._needValue(); if (unit === 'minutes') { if ( parent.classHas('disabled') && parent.classHas('range') ) { that.s.minutesRange = val as number; that._setTime(); return; } else { that.s.minutesRange = null; } } if (unit === 'seconds') { if ( parent.classHas('disabled') && parent.classHas('range') ) { that.s.secondsRange = val as number; that._setTime(); return; } else { that.s.secondsRange = null; } } // Specific to hours for 12h clock if (val === 'am') { if (d.getUTCHours() >= 12) { val = d.getUTCHours() - 12; } else { return; } } else if (val === 'pm') { if (d.getUTCHours() < 12) { val = d.getUTCHours() + 12; } else { return; } } var set = unit === 'hours' ? 'setUTCHours' : unit === 'minutes' ? 'setUTCMinutes' : 'setSeconds'; d[set](val); that._setCalander(); that._setTime(); that._writeOutput(true); onChange(); } else { // Calendar click d = that._needValue(); // Can't be certain that the current day will exist in // the new month, and likewise don't know that the // new day will exist in the old month, But 1 always // does, so we can change the month without worry of a // recalculation being done automatically by `Date` d.setUTCDate(1); d.setUTCFullYear(button.data('year')); d.setUTCMonth(button.data('month')); d.setUTCDate(button.data('day')); that._writeOutput(true); // Don't hide if there is a time picker, since we want to // be able to select a time as well. if (!that.s.parts.time) { // This is annoying but IE has some kind of async // behaviour with focus and the focus from the above // write would occur after this hide - resulting in // the calendar opening immediately setTimeout(function () { that._hide(); }, 10); } else { that._setCalander(); that._setTime(); } onChange(); } } else { // Click anywhere else in the widget - return focus to the // input element that.dom.input.focus(); } }); } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Private */ /** * Compare the date part only of two dates - this is made super easy by the * toDateString method! * * @param a Date 1 * @param b Date 2 */ private _compareDates(a: Date, b: Date) { // Can't use toDateString as that converts to local time // luxon uses different method names so need to be able to call them return this._isLuxon() ? dateLib.DateTime.fromJSDate(a).toUTC().toISODate() === dateLib.DateTime.fromJSDate(b).toUTC().toISODate() : this._dateToUtcString(a) === this._dateToUtcString(b); } /** * Convert from one format to another * * @param val Value * @param from Format to convert from. If null a `Date` must be given * @param to Format to convert to. If null a `Date` will be returned * @returns Converted value */ private _convert( val: string | Date, from: string | null, to: string | null ) { if (!val) { return val; } if (!dateLib) { // Note that in here from and to can either be null or YYYY-MM-DD // They cannot be anything else if ((!from && !to) || (from && to)) { // No conversion return val; } else if (!from && val instanceof Date) { // Date in, string back return ( val.getUTCFullYear() + '-' + this._pad(val.getUTCMonth() + 1) + '-' + this._pad(val.getUTCDate()) ); } else if (typeof val === 'string') { // (! to) // String in, date back var match = val.match(/(\d{4})\-(\d{2})\-(\d{2})/); return match ? new Date( parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3]) ) : null; } } else if (this._isLuxon()) { // Luxon var dtLux = val instanceof Date ? dateLib.DateTime.fromJSDate(val).toUTC() : dateLib.DateTime.fromFormat(val, from); if (!dtLux.isValid) { return null; } return to ? dtLux.toFormat(to) : dtLux.toJSDate(); } else { // Moment / DayJS var dtMo = val instanceof Date ? dateLib.utc(val, undefined, this.c.locale, this.c.strict) : dateLib(val, from, this.c.locale, this.c.strict); if (!dtMo.isValid()) { return null; } return to ? dtMo.format(to) : dtMo.toDate(); } } /** * When changing month, take account of the fact that some months don't have * the same number of days. For example going from January to February you * can have the 31st of Jan selected and just add a month since the date * would still be 31, and thus drop you into March. * * @param date Date - will be modified * @param month Month to set */ private _correctMonth(date: Date, month: number) { var days = this._daysInMonth(date.getUTCFullYear(), month); var correctDays = date.getUTCDate() > days; date.setUTCMonth(month); if (correctDays) { date.setUTCDate(days); date.setUTCMonth(month); } } /** * Get the number of days in a method. Based on * http://stackoverflow.com/a/4881951 by Matti Virkkunen * * @param year Year * @param month Month (starting at 0) */ private _daysInMonth(year: number, month: number) { // var isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); var months = [ 31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]; return months[month]; } /** * Create a new date object which has the UTC values set to the local time. * This allows the local time to be used directly for the library which * always bases its calculations and display on UTC. * * @param s Date to "convert" * @return Shifted date */ private _dateToUtc(s: Date) { if (!s) { return s; } return new Date( Date.UTC( s.getFullYear(), s.getMonth(), s.getDate(), s.getHours(), s.getMinutes(), s.getSeconds() ) ); } /** * Create a UTC ISO8601 date part from a date object * * @param d Date to "convert" * @return ISO formatted date */ private _dateToUtcString(d: Date) { // luxon uses different method names so need to be able to call them return this._isLuxon() ? dateLib.DateTime.fromJSDate(d).toUTC().toISODate() : d.getUTCFullYear() + '-' + this._pad(d.getUTCMonth() + 1) + '-' + this._pad(d.getUTCDate()); } /** * Hide the control and remove events related to its display * * @param destroy Flag to indicate that the instance is being destroyed */ private _hide(destroy = false) { if ( !destroy && (this.dom.input.attr('type') === 'hidden' || this.c.alwaysVisible) ) { // Normally we wouldn't need to redraw the calander if it changes // and then hides, but if it is hidden, then we do need to make sure // that it is correctly up to date. this._setCalander(); this._setTime(); return; } var namespace = this.s.namespace; this.dom.container.detach(); Dom.w.off('.' + namespace); Dom.s(document) .off('keydown.' + namespace) .off('keyup.' + namespace) .off('click.' + namespace); Dom.s('div.dt-scroll').off('scroll.' + namespace); Dom.s('div.DTE_Body_Content').off('scroll.' + namespace); Dom.s(this.dom.input.get(0).offsetParent).off('.' + namespace); } /** * Convert a 24 hour value to a 12 hour value * * @param val 24 hour value * @return 12 hour value */ private _hours24To12(val: number) { return val === 0 ? 12 : val > 12 ? val - 12 : val; } /** * Generate the HTML for a single day in the calendar - this is basically * and HTML cell with a button that has data attributes so we know what was * clicked on (if it is clicked on) and a bunch of classes for styling. * * @param {object} day Day object from the `_htmlMonth` method * @return {string} HTML cell */ private _htmlDay(day) { var classPrefix = this.c.classPrefix; if (day.empty) { return '<td class="' + classPrefix + '-empty"></td>'; } var classes = ['selectable']; if (day.disabled) { classes.push('disabled'); } if (day.today) { classes.push('now'); } if (day.selected) { classes.push('selected'); } return ( '<td data-day="' + day.day + '" class="' + classes.join(' ') + '">' + '<button class="' + classPrefix + '-button ' + classPrefix + '-day" type="button" ' + 'data-year="' + day.year + '" data-month="' + day.month + '" data-day="' + day.day + '">' + '<span>' + day.day + '</span>' + '</button>' + '</td>' ); } /** * Create the HTML for a month to be displayed in the calendar table. * * Based upon the logic used in Pikaday - MIT licensed * Copyright (c) 2014 David Bushell * https://github.com/dbushell/Pikaday * * @param year Year * @param month Month (starting at 0) * @return Calendar month HTML */ private _htmlMonth(year: number, month: number) { var now = this._dateToUtc(new Date()), days = this._daysInMonth(year, month), before = new Date(Date.UTC(year, month, 1)).getUTCDay(), data = [], row = []; if (this.c.firstDay === null) { this.c.firstDay = this._localeFirstDay(); } if (this.c.firstDay > 0) { before -= this.c.firstDay; if (before < 0) { before += 7; } } var cells = days + before, after = cells; while (after > 7) { after -= 7; } cells += 7 - after; var minDate = this.c.minDate; var maxDate = this.c.maxDate; if (minDate) { minDate.setUTCHours(0); minDate.setUTCMinutes(0); minDate.setSeconds(0); } if (maxDate) { maxDate.setUTCHours(23); maxDate.setUTCMinutes(59); maxDate.setSeconds(59); } for (var i = 0, r = 0; i < cells; i++) { var day = new Date(Date.UTC(year, month, 1 + (i - before))), selected = this.s.d ? this._compareDates(day, this.s.d) : false, today = this._compareDates(day, now), empty = i < before || i >= days + before, disabled = (minDate && day < minDate) || (maxDate && day > maxDate); var disableDays = this.c.disableDays; if ( Array.isArray(disableDays) && disableDays.includes(day.getUTCDay()) ) { disabled = true; } else if ( typeof disableDays === 'function' && disableDays(day) === true ) { disabled = true; } var dayConfig = { day: 1 + (i - before), month: month, year: year, selected: selected, today: today, disabled: disabled, empty: empty }; row.push(this._htmlDay(dayConfig)); if (++r === 7) { if (this.c.showWeekNumber) { row.unshift(this._htmlWeekOfYear(i - before, month, year)); } data.push('<tr>' + row.join('') + '</tr>'); row = []; r = 0; } } var classPrefix = this.c.classPrefix; var className = classPrefix + '-table'; if (this.c.showWeekNumber) { className += ' weekNumber'; } // Show / hide month icons based on min/max if (minDate) { var underMin = minDate >= new Date(Date.UTC(year, month, 1, 0, 0, 0)); this.dom.title .find('div.' + classPrefix + '-iconLeft') .css('display', underMin ? 'none' : 'block'); } if (maxDate) { var overMax = maxDate < new Date(Date.UTC(year, month + 1, 1, 0, 0, 0)); this.dom.title .find('div.' + classPrefix + '-iconRight') .css('display', overMax ? 'none' : 'block'); } return ( '<table class="' + className + '">' + '<thead>' + this._htmlMonthHead() + '</thead>' + '<tbody>' + data.join('') + '</tbody>' + '</table>' ); } /** * Create the calendar table's header (week days) * * @return {string} HTML cells for the row */ private _htmlMonthHead() { var a = []; var firstDay = this.c.firstDay; var i18n = this.c.i18n; // Take account of the first day shift var dayName = function (day) { day += firstDay; while (day >= 7) { day -= 7; } return i18n.weekdays[day]; }; // Empty cell in the header if (this.c.showWeekNumber) { a.push('<th></th>'); } for (var i = 0; i < 7; i++) { a.push('<th>' + dayName(i) + '</th>'); } return a.join(''); } /** * Create a cell that contains week of the year - ISO8601 * * Based on https://stackoverflow.com/questions/6117814/ and * http://techblog.procurios.nl/k/n618/news/view/33796/14863/ * * @param d Day of month * @param m Month of year (zero index) * @param y Year * @return HTML string for a day */ private _htmlWeekOfYear(d: number, m: number, y: number) { var date = new Date(y, m, d, 0, 0, 0, 0); // First week of the year always has 4th January in it date.setDate(date.getDate() + 4 - (date.getDay() || 7)); var oneJan = new Date(y, 0, 1); var weekNum = Math.ceil( (((date as any) - (oneJan as any)) / 86400000 + 1) / 7 ); return ( '<td class="' + this.c.classPrefix + '-week">' + weekNum + '</td>' ); } /** * Determine if Luxon is being used * * @returns Flag for Luxon */ private _isLuxon() { return dateLib && dateLib.DateTime && dateLib.Duration && dateLib.Settings ? true : false; } /** * Determine if Moment is being used * * @returns Flag for Moment */ private _isMoment() { return dateLib && typeof dateLib.isDate === 'function' && typeof dateLib.weekdays === 'function' ? true : false; } /** * Determine the first day of the week based on the current locale */ private _localeFirstDay() { let locale = this.c.locale; if (locale === 'en') { // Somewhat bonkers way to get the current locale, but there doesn't // seem to be a better way let browserLocale = new Intl.NumberFormat().resolvedOptions() .locale; // Not yet in Firefox if ((Intl as any).Locale) { let loc = new (Intl as any).Locale(browserLocale); if (loc.getWeekInfo) { let weekInfo = loc.getWeekInfo(); // Safari and new Chrome have it as a function if (typeof weekInfo.firstDay === 'function') { return weekInfo.firstDay(); } else if (typeof weekInfo.firstDay === 'number') { // Old Chrome as an accessor property return weekInfo.firstDay; } } } } if (this._isMoment()) { return dateLib.localeData(this.c.locale).firstDayOfWeek(); } // There doesn't appear to be a way to do it in luxon // Default to Monday return 1; } /** * Check if the instance has a date object value - it might be null. * If is doesn't set one to now. * @returns A Date object */ private _needValue() { if (!this.s.d) { this.s.d = this._dateToUtc(new Date()); if (!this.s.parts.time) { this.s.d.setUTCHours(0); this.s.d.setUTCMinutes(0); this.s.d.setSeconds(0); this.s.d.setMilliseconds(0); } } return this.s.d; } /** * Create option elements from a range in an array * * @param selector Class name unique to the select element to use * @param values Array of values * @param labels Array of labels. If given must be the same length as the * values parameter. */ private _options(selector: string, values: any[], labels?: any[]) { if (!labels) { labels = values; } var select = this.dom.container.find( 'select.' + this.c.classPrefix + '-' + selector ); select.empty(); for (var i = 0, ien = values.length; i < ien; i++) { select.append( Dom.c('option').attr('value', values[i]).text(labels[i]) ); } } /** * Set an option and update the option's span pair (since the select element * has opacity 0 for styling) * * @param selector Class name unique to the select element to use * @param val Value to set */ private _optionSet(selector: string, val: any) { var select = this.dom.container.find( 'select.' + this.c.classPrefix + '-' + selector ); var span = select.parent().children('span'); select.val(val); var selected = select .find('option') .filter(e => (e as HTMLOptionElement).selected); span.html( selected.count() !== 0 ? selected.text() : this.c.i18n.unknown ); } /** * Create time options list. * * @param unit Time unit - hours, minutes or seconds * @param count Count range - 12, 24 or 60 * @param val Existing value for this unit * @param allowed Values allow for selection * @param range Override range */ private _optionsTime( unit: string, count: number, val: number, allowed: number[], range?: number ) { var classPrefix = this.c.classPrefix; var container = this.dom.container.find( 'div.' + classPrefix + '-' + unit ); var i, j; var render = count === 12 ? function (i) { return i; } : this._pad; var className = classPrefix + '-table'; var i18n = this.c.i18n; if (!container.count()) { return; } var a = ''; var span = 10; var button = function (value, label, className = null) { // Shift the value for PM if (count === 12 && typeof value === 'number') { if (val >= 12) { value += 12; } if (value == 12) { value = 0; } else if (value == 24) { value = 12; } } var selected = val === value || (value === 'am' && val < 12) || (value === 'pm' && val >= 12) ? 'selected' : ''; if ( typeof value === 'number' && allowed && !allowed.includes(value) ) { selected += ' disabled'; } if (className) { selected += ' ' + className; } return ( '<td class="selectable ' + selected + '">' + '<button class="' + classPrefix + '-button ' + classPrefix + '-day" type="button" data-unit="' + unit + '" data-value="' + value + '">' + '<span>' + label + '</span>' + '</button>' + '</td>' ); }; if (count === 12) { // Hours with AM/PM a += '<tr>'; for (i = 1; i <= 6; i++) { a += button(i, render(i)); } a += button('am', i18n.amPm[0]); a += '</tr>'; a += '<tr>'; for (i = 7; i <= 12; i++) { a += button(i, render(i)); } a += button('pm', i18n.amPm[1]); a += '</tr>'; span = 7; } else if (count === 24) { // Hours - 24 var c = 0; for (j = 0; j < 4; j++) { a += '<tr>'; for (i = 0; i < 6; i++) { a += button(c, render(c)); c++; } a += '</tr>'; } span = 6; } else { // Minutes and seconds a += '<tr>'; for (j = 0; j < 60; j += 10) { a += button(j, render(j), 'range'); } a += '</tr>'; // Slight hack to allow for the different number of columns a += '</tbody></thead><table class="' + className + ' ' + className + '-nospace"><tbody>'; var start = range !== null ? range : val === -1 ? 0 : Math.floor(val / 10) * 10; a += '<tr>'; for (j = start + 1; j < start + 10; j++) { a += button(j, render(j)); } a += '</tr>'; span = 6; } container .empty() .html( '<table class="' + className + '">' + '<thead><tr><th colspan="' + span + '">' + i18n[unit] + '</th></tr></thead>' + '<tbody>' + a + '</tbody>' + '</table>' ); } /** * Create the options for the month and year */ private _optionsTitle() { var i18n = this.c.i18n; var min = this.c.minDate; var max = this.c.maxDate; var minYear = min ? min.getFullYear() : null; var maxYear = max ? max.getFullYear() : null; var i = minYear !== null ? minYear : new Date().getFullYear() - this.c.yearRange; var j = maxYear !== null ? maxYear : new Date().getFullYear() + this.c.yearRange; this._options('month', this._range(0, 11), i18n.months); this._options('year', this._range(i, j)); // Set the language strings in case any have changed this.dom.today.text(i18n.today).text(i18n.today); this.dom.selected.text(i18n.selected).text(i18n.selected); this.dom.clear.text(i18n.clear).text(i18n.clear); this.dom.previous .attr('title', i18n.previous) .children('button') .text(i18n.previous); this.dom.next .attr('title', i18n.next) .children('button') .text(i18n.next); } /** * Simple two digit pad * * @param {integer} i Value that might need padding * @return {string|integer} Padded value */ private _pad(i) { return i < 10 ? '0' + i : i; } /** * Position the calendar to look attached to the input element */ private _position() { var offset = this.c.attachTo === 'input' ? this.dom.input.position() : this.dom.input.offset(); var container = this.dom.container; var inputHeight = this.dom.input.height('outer'); if (container.classHas('inline')) { container.insertAfter(this.dom.input); return; } if (this.s.parts.date && this.s.parts.time && Dom.w.width() > 550) { container.classAdd('horizontal'); } else { container.classRemove('horizontal'); } if (this.c.attachTo === 'input') { container .css({ top: offset.top + inputHeight + 'px', left: offset.left + 'px' }) .insertAfter(this.dom.input); } else { container .css({ top: offset.top + inputHeight + 'px', left: offset.left + 'px' }) .appendTo('body'); } var calHeight = container.height('outer'); var calWidth = container.width('outer'); var scrollTop = Dom.w.scrollTop(); // Correct to the bottom if (offset.top + inputHeight + calHeight - scrollTop > Dom.w.height()) { var newTop = offset.top - calHeight; container.css('top', newTop < 0 ? '0' : newTop + 'px'); } // Correct to the right if (calWidth + offset.left > Dom.w.width()) { var newLeft = Dom.w.width() - calWidth - 5; // Account for elements which are inside a position absolute element if (this.c.attachTo === 'input') { newLeft -= container .map(e => e.offsetParent as HTMLElement) .offset().left; } container.css('left', newLeft < 0 ? '0' : newLeft + 'px'); } } /** * Create a simple array with a range of values * * @param start Start value (inclusive) * @param end End value (inclusive) * @param inc Increment value * @return Created array */ private _range(start: number, end: number, inc = 1) { var a: number[] = []; for (var i = start; i <= end; i += inc) { a.push(i); } return a; } /** * Redraw the calendar based on the display date - this is a destructive * operation */ private _setCalander() { if (this.s.display) { this.dom.calendar .empty() .html( this._htmlMonth( this.s.display.getUTCFullYear(), this.s.display.getUTCMonth() ) ); } } /** * Set the month and year for the calendar based on the current display date */ private _setTitle() { this._optionSet('month', this.s.display.getUTCMonth()); this._optionSet('year', this.s.display.getUTCFullYear()); } /** * Set the time based on the current value of the widget */ private _setTime() { var that = this; var d = this.s.d; // luxon uses different method names so need to be able to call them. // This happens a few time later in this method too var luxDT = null; if (this._isLuxon()) { luxDT = dateLib.DateTime.fromJSDate(d).toUTC(); } var hours = luxDT != null ? luxDT.hour : d ? d.getUTCHours() : -1; var allowed = function (prop) { // Backwards compt with `Increment` option return that.c[prop + 'Available'] ? that.c[prop + 'Available'] : that._range(0, 59, that.c[prop + 'Increment']); }; this._optionsTime( 'hours', this.s.parts.hours12 ? 12 : 24, hours, this.c.hoursAvailable ); this._optionsTime( 'minutes', 60, luxDT != null ? luxDT.minute : d ? d.getUTCMinutes() : -1, allowed('minutes'), this.s.minutesRange ); this._optionsTime( 'seconds', 60, luxDT != null ? luxDT.second : d ? d.getSeconds() : -1, allowed('seconds'), this.s.secondsRange ); } /** * Show the widget and add events to the document required only while it * is displayed * */ private _show() { var that = this; var namespace = this.s.namespace; this._position(); // Need to reposition on scroll Dom.w.on('scroll.' + namespace + ' resize.' + namespace, function () { that._position(); }); Dom.s('div.DTE_Body_Content').on('scroll.' + namespace, function () { that._position(); }); Dom.s('div.dt-scroll').on('scroll.' + namespace, function () { that._position(); }); var offsetParent = this.dom.input.get(0).offsetParent; if (offsetParent !== document.body) { Dom.s(offsetParent).on('scroll.' + namespace, function () { that._position(); }); } // On tab focus will move to a different field (no keyboard navigation // in the date picker - this might need to be changed). Dom.s(document).on('keydown.' + namespace, function (e) { if ( that.dom.container.isVisible() && (e.keyCode === 9 || // tab e.keyCode === 13) // return ) { that._hide(); } }); // Esc is on keyup to allow Editor to know that the container was hidden // and thus not act on the esc itself. Dom.s(document).on('keyup.' + namespace, function (e) { if (that.dom.container.isVisible() && e.keyCode === 27) { // esc e.preventDefault(); that._hide(); } }); clearTimeout(this.s.showTo); // We can't use blur to hide, as we want to keep the picker open while // to let the user select from it. But if focus is moved outside of of // the picker, then we auto hide. this.dom.input.on('blur', function (e) { that.s.showTo = setTimeout(function () { let name = document.activeElement.tagName.toLowerCase(); if (document.activeElement === that.dom.input.get(0)) { return; } if (that.dom.container.find(document.activeElement).count()) { return; } if (['input', 'select', 'button'].includes(name)) { that.hide(); } }, 10); }); // Hide if clicking outside of the widget - but in a different click // event from the one that was used to trigger the show (bubble and // inline) setTimeout(function () { Dom.s(document).on('click.' + namespace, function (e) { if ( !Dom.s(e.target) .closest(that.dom.container.get(0)) .count() && e.target !== that.dom.input.get(0) ) { that._hide(); } }); }, 10); } /** * Write the formatted string to the input element this control is attached * to */ private _writeOutput(focus = false, change = true) { var date = this.s.d; var out = ''; var input = this.dom.input; if (date) { out = this._convert(date, null, this.c.format); } input.val(out); if (change) { // Create a DOM synthetic event. Can't use $().trigger() as // that doesn't actually trigger non-jQuery event listeners var event = new Event('change', { bubbles: true }); input.get(0).dispatchEvent(event); } if (input.attr('type') === 'hidden') { this.val(out, false); } if (focus) { input.focus(); } } } // Global export - if no conflicts // TODO is this right in the UDM? if (!(window as any).DateTime) { (window as any).DateTime = DateTime; } // Make available via jQuery if ((window as any).jQuery) { (window as any).jQuery.fn.dtDateTime = function (options) { return this.each(function () { new DateTime(this, options); }); }; } // Attach to DataTables DataTable.DateTime = DateTime; DataTable.use('datetime', DateTime); // And to Editor (legacy) if ((DataTable as any).Editor) { (DataTable as any).Editor.DateTime = DateTime; }