import datetime from 'tail.datetime'; import './datepicker.css'; import { Utility } from '../../core/utility'; import moment from 'moment'; import * as defer from 'lodash.defer'; const KEYCODE_ESCAPE = 27; const Z_INDEX_MODAL = 9999; // should be the same as ATTR_SUBMIT_LOCKED in async-table util // TODO move to global config const ATTR_DATEPICKER_OPEN = 'submit-locked'; // INTERNAL (Uni2work specific) formats for formatting dates and/or times const FORM_DATE_FORMAT = { 'date': moment.HTML5_FMT.DATE, 'time': moment.HTML5_FMT.TIME_SECONDS, 'datetime-local': moment.HTML5_FMT.DATETIME_LOCAL_SECONDS, }; // FANCY (tail.datetime specific) formats for displaying dates and/or times const FORM_DATE_FORMAT_DATE_DT = 'dd.mm.YYYY'; const FORM_DATE_FORMAT_TIME_DT = 'HH:ii:ss'; // FANCY (moment specific) formats for displaying dates and/or times const FORM_DATE_FORMAT_DATE_MOMENT = 'DD.MM.YYYY'; const FORM_DATE_FORMAT_TIME_MOMENT = 'HH:mm:ss'; const FORM_DATE_FORMAT_MOMENT = { 'date': FORM_DATE_FORMAT_DATE_MOMENT, 'time': FORM_DATE_FORMAT_TIME_MOMENT, 'datetime-local': `${FORM_DATE_FORMAT_DATE_MOMENT} ${FORM_DATE_FORMAT_TIME_MOMENT}`, }; const DATEPICKER_UTIL_SELECTOR = 'input[type="date"], input[type="time"], input[type="datetime-local"]'; const DATEPICKER_INITIALIZED_CLASS = 'datepicker--initialized'; const DATEPICKER_OPEN_CLASS = 'calendar-open'; const DATEPICKER_CONFIG = { 'global': { // set default time to 00:00:00 timeHours: 0, timeMinutes: 0, timeSeconds: 0, weekStart: 1, dateFormat: FORM_DATE_FORMAT_DATE_DT, timeFormat: FORM_DATE_FORMAT_TIME_DT, // prevent the instance from closing when selecting a date before selecting a time stayOpen: true, // hide the close button (we handle closing the datepicker manually by clicking outside) closeButton: false, // disable the decades view because nobody will ever need it (i.e. cap the switch to the more relevant year view) viewDecades: false, }, 'datetime-local': {}, 'date': { // disable date picker timeFormat: false, }, 'time': { // disable time picker dateFormat: false, }, }; @Utility({ selector: DATEPICKER_UTIL_SELECTOR, }) export class Datepicker { // singleton Map that maps a formID to a Map of Datepicker objects static datepickerCollections; datepickerInstance; _element; elementType; initialValue; _locale; _unloadIsDueToSubmit = false; constructor(element) { if (!element) { throw new Error('Datepicker utility needs to be passed an element!'); } if (element.classList.contains(DATEPICKER_INITIALIZED_CLASS)) { return false; } this._locale = window.App.i18n.getDatetimeLocale(); // initialize datepickerCollections singleton if not already done if (!Datepicker.datepickerCollections) { Datepicker.datepickerCollections = new Map(); } this._element = element; // store the previously set type to select the input format this.elementType = this._element.getAttribute('type'); // store initial value prior to changing type this.initialValue = this._element.value || this._element.getAttribute('value'); // manually set the type attribute to text because datepicker handles displaying the date this._element.setAttribute('type', 'text'); // get all relevant config options for this datepicker type const datepickerGlobalConfig = DATEPICKER_CONFIG['global']; const datepickerConfig = DATEPICKER_CONFIG[this.elementType]; // additional position config (optional data-datepicker-position attribute in html) that can specialize the global config const datepickerPosition = this._element.dataset.datepickerPosition; if (datepickerPosition) { datepickerGlobalConfig.position = datepickerPosition; } if (!datepickerConfig || !FORM_DATE_FORMAT[this.elementType]) { throw new Error('Datepicker utility called on unsupported element!'); } // FIXME dirty hack below; fix tail.datetime instead // get date object from internal format before datetime does nasty things with it let parsedMomentDate = moment(this.initialValue, [ FORM_DATE_FORMAT[this.elementType], FORM_DATE_FORMAT_MOMENT[this.elementType] ], true); if (parsedMomentDate && parsedMomentDate.isValid()) { parsedMomentDate = parsedMomentDate.toDate(); } else { parsedMomentDate = undefined; } // initialize tail.datetime (datepicker) instance and let it do weird stuff with the element value this.datepickerInstance = datetime(this._element, { ...datepickerGlobalConfig, ...datepickerConfig, locale: this._locale }); // reset date to something sane if (parsedMomentDate) this.datepickerInstance.selectDate(parsedMomentDate); // insert the datepicker element (dt) after the form this._element.form.parentNode.insertBefore(this.datepickerInstance.dt, this._element.form.nextSibling); // if the input element is in any open modal, increase the z-index of the datepicker if (this._element.closest('.modal--open')) { this.datepickerInstance.dt.style.zIndex = Z_INDEX_MODAL; } // register this datepicker instance with the formID of the given element in the datepicker collection const formID = this._element.form.id; const elemID = this._element.id; if (!Datepicker.datepickerCollections.has(formID)) { // insert a new key value pair if the formID key is not there already Datepicker.datepickerCollections.set(formID, new Map([[elemID, this]])); } else { // otherwise, insert this instance into the Map Datepicker.datepickerCollections.get(formID).set(elemID, this); } // mark the form input element as initialized this._element.classList.add(DATEPICKER_INITIALIZED_CLASS); } start() { const setDatepickerDate = () => { // try to parse the current input element value with fancy and internal format string const parsedMomentDate = moment(this._element.value, FORM_DATE_FORMAT_MOMENT[this.elementType]); const parsedMomentDateInternal = moment(this._element.value, FORM_DATE_FORMAT[this.elementType]); // only set the datepicker date if the input is either in valid fancy format or in valid internal format if (parsedMomentDate.isValid()) { this.datepickerInstance.selectDate(parsedMomentDate.toDate()); } else if (parsedMomentDateInternal.isValid()) { this.datepickerInstance.selectDate(parsedMomentDateInternal.toDate()); } // reregister change event to prevent event loop this._element.addEventListener('change', setDatepickerDate, { once: true }); }; // change the selected date in the tail.datetime instance if the value of the input element is changed this._element.addEventListener('change', setDatepickerDate, { once: true }); // create a mutation observer that observes the datepicker instance class and sets // the datepicker-open DOM attribute of the input element if the datepicker has been opened const datepickerInstanceObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (!mutation.oldValue.includes(DATEPICKER_OPEN_CLASS) && this.datepickerInstance.dt.getAttribute('class').includes(DATEPICKER_OPEN_CLASS)) { this._element.setAttribute(ATTR_DATEPICKER_OPEN, true); break; } } }); datepickerInstanceObserver.observe(this.datepickerInstance.dt, { attributes: true, attributeFilter: ['class'], attributeOldValue: true, }); // close the instance on focusout of any element if another input is focussed that is neither the timepicker nor _element window.addEventListener('focusout', event => { const hasFocus = event.relatedTarget !== null; const focussedIsNotTimepicker = !this.datepickerInstance.dt.contains(event.relatedTarget); const focussedIsNotElement = event.relatedTarget !== this._element; const focussedIsInDocument = window.document.contains(event.relatedTarget); if (hasFocus && focussedIsNotTimepicker && focussedIsNotElement && focussedIsInDocument) this.closeDatepickerInstance(); }); // close the instance on click on any element outside of the datepicker (except the input element itself) window.addEventListener('click', event => { const targetIsOutside = !this.datepickerInstance.dt.contains(event.target) && event.target !== this.datepickerInstance.dt; const targetIsInDocument = window.document.contains(event.target); const targetIsNotElement = event.target !== this._element; if (targetIsOutside && targetIsInDocument && targetIsNotElement) this.closeDatepickerInstance(); }); // close the instance on escape keydown events this._element.addEventListener('keydown', event => { if (event.keyCode === KEYCODE_ESCAPE) { this.closeDatepickerInstance(); } }); // format the date value of the form input element of this datepicker before form submission this._element.form.addEventListener('submit', this._submitHandler.bind(this)); window.addEventListener('beforeunload', this._beforeUnloadHandler.bind(this)); } destroy() { this.datepickerInstance.remove(); } // DATEPICKER INSTANCE CONTROL /** * Closes the datepicker instance, releasing the lock on the input element. */ closeDatepickerInstance() { if (!this._element.datepicker-open) { throw new Error('Cannot close already closed datepicker instance!'); } this._element.setAttribute(ATTR_DATEPICKER_OPEN, false); this.datepickerInstance.close(); } // FORMAT METHODS /** * Formats the value of this input element from datepicker format (i.e. DATEPICKER_CONFIG.dateFormat + " " + datetime.defaults.timeFormat) to Uni2work internal date format (i.e. FORM_DATE_FORMAT) required for form submission * @param {*} toFancy optional target format switch (boolean value; default is false). If set to a truthy value, formats the element value to fancy instead of internal date format. */ formatElementValue(toFancy) { if (this._element.value) { this._element.value = this.unformat(toFancy); } } _submitHandler() { this._unloadIsDueToSubmit = true; this.formatElementValue(false); defer(() => { // Restore state after event loop is settled this._unloadIsDueToSubmit = false; this.formatElementValue(true); }); } /** * Restore input element to it's original type and format it's new value for input-value persisting by the browser */ _beforeUnloadHandler() { if (this._unloadIsDueToSubmit) return; let oldValue = this._element.value; let newValue = this.unformat(false); this._element.type = this.elementType; this._element.value = newValue; defer(() => { // Restore state after event loop is settled this._element.setAttribute('type', 'text'); this._element.value = oldValue; }); } /** * Returns a datestring in internal format from the current state of the input element value. * @param {*} toFancy Format date from internal to fancy or vice versa. When omitted, toFancy is falsy and results in fancy -> internal */ unformat(toFancy) { const formatIn = toFancy ? FORM_DATE_FORMAT[this.elementType] : FORM_DATE_FORMAT_MOMENT[this.elementType]; const formatOut = toFancy ? FORM_DATE_FORMAT_MOMENT[this.elementType] : FORM_DATE_FORMAT[this.elementType]; return reformatDateString(this._element.value, formatIn, formatOut); } /** * Takes a Form and a FormData and returns a new FormData with all dates formatted to uni2work date format. This function will not change the value of the date input elements. * @param {*} form Form for which all dates will be formatted in the FormData * @param {*} formData Initial FormData */ static unformatAll(form, formData) { // only proceed if there are any datepickers and if both form and formData are defined if (Datepicker.datepickerCollections && form && formData) { // if the form has no id, assign one randomly if (!form.id) { form.id = `f${Math.floor(Math.random() * 100000)}`; } const formId = form.id; if (Datepicker.datepickerCollections.has(formId)) { const datepickerInstances = Datepicker.datepickerCollections.get(formId); datepickerInstances.forEach(instance => { formData.set(instance._element.name, instance.unformat()); }); } } // return the (possibly changed) FormData return formData; } } // HELPER FUNCTIONS /** * Takes a string representation of a date, an input ('previous') format and a desired output format and returns a reformatted date string. * If the date string is not valid (i.e. cannot be parsed with the given input format string), returns the original date string; * @param {*} dateStr string representation of a date (needs to be in format formatIn) * @param {*} formatIn input format string * @param {*} formatOut format string of the desired output date string */ function reformatDateString(dateStr, formatIn, formatOut) { const parsedMomentDate = moment(dateStr, [formatIn, formatOut]); return parsedMomentDate.isValid() ? parsedMomentDate.format(formatOut) : dateStr; }