import {lastItemOf, stringToArray, isInRange} from './lib/utils.js'; import {today} from './lib/date.js'; import {parseDate, formatDate} from './lib/date-format.js'; import {registerListeners, unregisterListeners} from './lib/event.js'; import {locales} from './i18n/base-locales.js'; import defaultOptions from './options/defaultOptions.js'; import processOptions from './options/processOptions.js'; import Picker from './picker/Picker.js'; import {triggerDatepickerEvent} from './events/functions.js'; import {onKeydown, onFocus, onMousedown, onClickInput, onPaste} from './events/inputFieldListeners.js'; import {onClickOutside} from './events/otherListeners.js'; function stringifyDates(dates, config) { return dates .map(dt => formatDate(dt, config.format, config.locale)) .join(config.dateDelimiter); } // parse input dates and create an array of time values for selection // returns undefined if there are no valid dates in inputDates // when origDates (current selection) is passed, the function works to mix // the input dates into the current selection function processInputDates(datepicker, inputDates, clear = false) { const {config, dates: origDates, rangepicker} = datepicker; if (inputDates.length === 0) { // empty input is considered valid unless origiDates is passed return clear ? [] : undefined; } const rangeEnd = rangepicker && datepicker === rangepicker.datepickers[1]; let newDates = inputDates.reduce((dates, dt) => { let date = parseDate(dt, config.format, config.locale); if (date === undefined) { return dates; } if (config.pickLevel > 0) { // adjust to 1st of the month/Jan 1st of the year // or to the last day of the monh/Dec 31st of the year if the datepicker // is the range-end picker of a rangepicker const dt = new Date(date); if (config.pickLevel === 1) { date = rangeEnd ? dt.setMonth(dt.getMonth() + 1, 0) : dt.setDate(1); } else { date = rangeEnd ? dt.setFullYear(dt.getFullYear() + 1, 0, 0) : dt.setMonth(0, 1); } } if ( isInRange(date, config.minDate, config.maxDate) && !dates.includes(date) && !config.datesDisabled.includes(date) && !config.daysOfWeekDisabled.includes(new Date(date).getDay()) ) { dates.push(date); } return dates; }, []); if (newDates.length === 0) { return; } if (config.multidate && !clear) { // get the synmetric difference between origDates and newDates newDates = newDates.reduce((dates, date) => { if (!origDates.includes(date)) { dates.push(date); } return dates; }, origDates.filter(date => !newDates.includes(date))); } // do length check always because user can input multiple dates regardless of the mode return config.maxNumberOfDates && newDates.length > config.maxNumberOfDates ? newDates.slice(config.maxNumberOfDates * -1) : newDates; } // refresh the UI elements // modes: 1: input only, 2, picker only, 3 both function refreshUI(datepicker, mode = 3, quickRender = true) { const {config, picker, inputField} = datepicker; if (mode & 2) { const newView = picker.active ? config.pickLevel : config.startView; picker.update().changeView(newView).render(quickRender); } if (mode & 1 && inputField) { inputField.value = stringifyDates(datepicker.dates, config); } } function setDate(datepicker, inputDates, options) { let {clear, render, autohide} = options; if (render === undefined) { render = true; } if (!render) { autohide = false; } else if (autohide === undefined) { autohide = datepicker.config.autohide; } const newDates = processInputDates(datepicker, inputDates, clear); if (!newDates) { return; } if (newDates.toString() !== datepicker.dates.toString()) { datepicker.dates = newDates; refreshUI(datepicker, render ? 3 : 1); triggerDatepickerEvent(datepicker, 'changeDate'); } else { refreshUI(datepicker, 1); } if (autohide) { datepicker.hide(); } } /** * Class representing a date picker */ export default class Datepicker { /** * Create a date picker * @param {Element} element - element to bind a date picker * @param {Object} [options] - config options * @param {DateRangePicker} [rangepicker] - DateRangePicker instance the * date picker belongs to. Use this only when creating date picker as a part * of date range picker */ constructor(element, options = {}, rangepicker = undefined) { element.datepicker = this; this.element = element; // set up config const config = this.config = Object.assign({ buttonClass: (options.buttonClass && String(options.buttonClass)) || 'button', container: document.body, defaultViewDate: today(), maxDate: undefined, minDate: undefined, }, processOptions(defaultOptions, this)); this._options = options; Object.assign(config, processOptions(options, this)); // configure by type const inline = this.inline = element.tagName !== 'INPUT'; let inputField; let initialDates; if (inline) { config.container = element; initialDates = stringToArray(element.dataset.date, config.dateDelimiter); delete element.dataset.date; } else { const container = options.container ? document.querySelector(options.container) : null; if (container) { config.container = container; } inputField = this.inputField = element; inputField.classList.add('datepicker-input'); initialDates = stringToArray(inputField.value, config.dateDelimiter); } if (rangepicker) { // check validiry const index = rangepicker.inputs.indexOf(inputField); const datepickers = rangepicker.datepickers; if (index < 0 || index > 1 || !Array.isArray(datepickers)) { throw Error('Invalid rangepicker object.'); } // attach itaelf to the rangepicker here so that processInputDates() can // determine if this is the range-end picker of the rangepicker while // setting inital values when pickLevel > 0 datepickers[index] = this; // add getter for rangepicker Object.defineProperty(this, 'rangepicker', { get() { return rangepicker; }, }); } // set initial dates this.dates = []; // process initial value const inputDateValues = processInputDates(this, initialDates); if (inputDateValues && inputDateValues.length > 0) { this.dates = inputDateValues; } if (inputField) { inputField.value = stringifyDates(this.dates, config); } const picker = this.picker = new Picker(this); if (inline) { this.show(); } else { // set up event listeners in other modes const onMousedownDocument = onClickOutside.bind(null, this); const listeners = [ [inputField, 'keydown', onKeydown.bind(null, this)], [inputField, 'focus', onFocus.bind(null, this)], [inputField, 'mousedown', onMousedown.bind(null, this)], [inputField, 'click', onClickInput.bind(null, this)], [inputField, 'paste', onPaste.bind(null, this)], [document, 'mousedown', onMousedownDocument], [document, 'touchstart', onMousedownDocument], [window, 'resize', picker.place.bind(picker)] ]; registerListeners(this, listeners); } } /** * Format Date object or time value in given format and language * @param {Date|Number} date - date or time value to format * @param {String|Object} format - format string or object that contains * toDisplay() custom formatter, whose signature is * - args: * - date: {Date} - Date instance of the date passed to the method * - format: {Object} - the format object passed to the method * - locale: {Object} - locale for the language specified by `lang` * - return: * {String} formatted date * @param {String} [lang=en] - language code for the locale to use * @return {String} formatted date */ static formatDate(date, format, lang) { return formatDate(date, format, lang && locales[lang] || locales.en); } /** * Parse date string * @param {String|Date|Number} dateStr - date string, Date object or time * value to parse * @param {String|Object} format - format string or object that contains * toValue() custom parser, whose signature is * - args: * - dateStr: {String|Date|Number} - the dateStr passed to the method * - format: {Object} - the format object passed to the method * - locale: {Object} - locale for the language specified by `lang` * - return: * {Date|Number} parsed date or its time value * @param {String} [lang=en] - language code for the locale to use * @return {Number} time value of parsed date */ static parseDate(dateStr, format, lang) { return parseDate(dateStr, format, lang && locales[lang] || locales.en); } /** * @type {Object} - Installed locales in `[languageCode]: localeObject` format * en`:_English (US)_ is pre-installed. */ static get locales() { return locales; } /** * @type {Boolean} - Whether the picker element is shown. `true` whne shown */ get active() { return !!(this.picker && this.picker.active); } /** * @type {HTMLDivElement} - DOM object of picker element */ get pickerElement() { return this.picker ? this.picker.element : undefined; } /** * Set new values to the config options * @param {Object} options - config options to update */ setOptions(options) { const picker = this.picker; const newOptions = processOptions(options, this); Object.assign(this._options, options); Object.assign(this.config, newOptions); picker.setOptions(newOptions); refreshUI(this, 3); } /** * Show the picker element */ show() { if (this.inputField) { if (this.inputField.disabled) { return; } if (this.inputField !== document.activeElement) { this._showing = true; this.inputField.focus(); delete this._showing; } } this.picker.show(); } /** * Hide the picker element * Not available on inline picker */ hide() { if (this.inline) { return; } this.picker.hide(); this.picker.update().changeView(this.config.startView).render(); } /** * Destroy the Datepicker instance * @return {Detepicker} - the instance destroyed */ destroy() { this.hide(); unregisterListeners(this); this.picker.detach(); if (!this.inline) { this.inputField.classList.remove('datepicker-input'); } delete this.element.datepicker; return this; } /** * Get the selected date(s) * * The method returns a Date object of selected date by default, and returns * an array of selected dates in multidate mode. If format string is passed, * it returns date string(s) formatted in given format. * * @param {String} [format] - Format string to stringify the date(s) * @return {Date|String|Date[]|String[]} - selected date(s), or if none is * selected, empty array in multidate mode and untitled in sigledate mode */ getDate(format = undefined) { const callback = format ? date => formatDate(date, format, this.config.locale) : date => new Date(date); if (this.config.multidate) { return this.dates.map(callback); } if (this.dates.length > 0) { return callback(this.dates[0]); } } /** * Set selected date(s) * * In multidate mode, you can pass multiple dates as a series of arguments * or an array. (Since each date is parsed individually, the type of the * dates doesn't have to be the same.) * The given dates are used to toggle the select status of each date. The * number of selected dates is kept from exceeding the length set to * maxNumberOfDates. * * With clear: true option, the method can be used to clear the selection * and to replace the selection instead of toggling in multidate mode. * If the option is passed with no date arguments or an empty dates array, * it works as "clear" (clear the selection then set nothing), and if the * option is passed with new dates to select, it works as "replace" (clear * the selection then set the given dates) * * When render: false option is used, the method omits re-rendering the * picker element. In this case, you need to call refresh() method later in * order for the picker element to reflect the changes. The input field is * refreshed always regardless of this option. * * When invalid (unparsable, repeated, disabled or out-of-range) dates are * passed, the method ignores them and applies only valid ones. In the case * that all the given dates are invalid, which is distinguished from passing * no dates, the method considers it as an error and leaves the selection * untouched. * * @param {...(Date|Number|String)|Array} [dates] - Date strings, Date * objects, time values or mix of those for new selection * @param {Object} [options] - function options * - clear: {boolean} - Whether to clear the existing selection * defualt: false * - render: {boolean} - Whether to re-render the picker element * default: true * - autohide: {boolean} - Whether to hide the picker element after re-render * Ignored when used with render: false * default: config.autohide */ setDate(...args) { const dates = [...args]; const opts = {}; const lastArg = lastItemOf(args); if ( typeof lastArg === 'object' && !Array.isArray(lastArg) && !(lastArg instanceof Date) && lastArg ) { Object.assign(opts, dates.pop()); } const inputDates = Array.isArray(dates[0]) ? dates[0] : dates; setDate(this, inputDates, opts); } /** * Update the selected date(s) with input field's value * Not available on inline picker * * The input field will be refreshed with properly formatted date string. * * @param {Object} [options] - function options * - autohide: {boolean} - whether to hide the picker element after refresh * default: false */ update(options = undefined) { if (this.inline) { return; } const opts = {clear: true, autohide: !!(options && options.autohide)}; const inputDates = stringToArray(this.inputField.value, this.config.dateDelimiter); setDate(this, inputDates, opts); } /** * Refresh the picker element and the associated input field * @param {String} [target] - target item when refreshing one item only * 'picker' or 'input' * @param {Boolean} [forceRender] - whether to re-render the picker element * regardless of its state instead of optimized refresh */ refresh(target = undefined, forceRender = false) { if (target && typeof target !== 'string') { forceRender = target; target = undefined; } let mode; if (target === 'picker') { mode = 2; } else if (target === 'input') { mode = 1; } else { mode = 3; } refreshUI(this, mode, !forceRender); } /** * Enter edit mode * Not available on inline picker or when the picker element is hidden */ enterEditMode() { if (this.inline || !this.picker.active || this.editMode) { return; } this.editMode = true; this.inputField.classList.add('in-edit'); } /** * Exit from edit mode * Not available on inline picker * @param {Object} [options] - function options * - update: {boolean} - whether to call update() after exiting * If false, input field is revert to the existing selection * default: false */ exitEditMode(options = undefined) { if (this.inline || !this.editMode) { return; } const opts = Object.assign({update: false}, options); delete this.editMode; this.inputField.classList.remove('in-edit'); if (opts.update) { this.update(opts); } } }