330 lines
12 KiB
JavaScript
330 lines
12 KiB
JavaScript
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));
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|