488 lines
16 KiB
JavaScript
488 lines
16 KiB
JavaScript
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', 'border-blue-700', '!border-primary-700');
|
|
}
|
|
|
|
/**
|
|
* 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', 'border-blue-700', '!border-primary-700');
|
|
if (opts.update) {
|
|
this.update(opts);
|
|
}
|
|
}
|
|
}
|