Datepicker.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import {lastItemOf, stringToArray, isInRange} from './lib/utils.js';
  2. import {today} from './lib/date.js';
  3. import {parseDate, formatDate} from './lib/date-format.js';
  4. import {registerListeners, unregisterListeners} from './lib/event.js';
  5. import {locales} from './i18n/base-locales.js';
  6. import defaultOptions from './options/defaultOptions.js';
  7. import processOptions from './options/processOptions.js';
  8. import Picker from './picker/Picker.js';
  9. import {triggerDatepickerEvent} from './events/functions.js';
  10. import {onKeydown, onFocus, onMousedown, onClickInput, onPaste} from './events/inputFieldListeners.js';
  11. import {onClickOutside} from './events/otherListeners.js';
  12. function stringifyDates(dates, config) {
  13. return dates
  14. .map(dt => formatDate(dt, config.format, config.locale))
  15. .join(config.dateDelimiter);
  16. }
  17. // parse input dates and create an array of time values for selection
  18. // returns undefined if there are no valid dates in inputDates
  19. // when origDates (current selection) is passed, the function works to mix
  20. // the input dates into the current selection
  21. function processInputDates(datepicker, inputDates, clear = false) {
  22. const {config, dates: origDates, rangepicker} = datepicker;
  23. if (inputDates.length === 0) {
  24. // empty input is considered valid unless origiDates is passed
  25. return clear ? [] : undefined;
  26. }
  27. const rangeEnd = rangepicker && datepicker === rangepicker.datepickers[1];
  28. let newDates = inputDates.reduce((dates, dt) => {
  29. let date = parseDate(dt, config.format, config.locale);
  30. if (date === undefined) {
  31. return dates;
  32. }
  33. if (config.pickLevel > 0) {
  34. // adjust to 1st of the month/Jan 1st of the year
  35. // or to the last day of the monh/Dec 31st of the year if the datepicker
  36. // is the range-end picker of a rangepicker
  37. const dt = new Date(date);
  38. if (config.pickLevel === 1) {
  39. date = rangeEnd
  40. ? dt.setMonth(dt.getMonth() + 1, 0)
  41. : dt.setDate(1);
  42. } else {
  43. date = rangeEnd
  44. ? dt.setFullYear(dt.getFullYear() + 1, 0, 0)
  45. : dt.setMonth(0, 1);
  46. }
  47. }
  48. if (
  49. isInRange(date, config.minDate, config.maxDate)
  50. && !dates.includes(date)
  51. && !config.datesDisabled.includes(date)
  52. && !config.daysOfWeekDisabled.includes(new Date(date).getDay())
  53. ) {
  54. dates.push(date);
  55. }
  56. return dates;
  57. }, []);
  58. if (newDates.length === 0) {
  59. return;
  60. }
  61. if (config.multidate && !clear) {
  62. // get the synmetric difference between origDates and newDates
  63. newDates = newDates.reduce((dates, date) => {
  64. if (!origDates.includes(date)) {
  65. dates.push(date);
  66. }
  67. return dates;
  68. }, origDates.filter(date => !newDates.includes(date)));
  69. }
  70. // do length check always because user can input multiple dates regardless of the mode
  71. return config.maxNumberOfDates && newDates.length > config.maxNumberOfDates
  72. ? newDates.slice(config.maxNumberOfDates * -1)
  73. : newDates;
  74. }
  75. // refresh the UI elements
  76. // modes: 1: input only, 2, picker only, 3 both
  77. function refreshUI(datepicker, mode = 3, quickRender = true) {
  78. const {config, picker, inputField} = datepicker;
  79. if (mode & 2) {
  80. const newView = picker.active ? config.pickLevel : config.startView;
  81. picker.update().changeView(newView).render(quickRender);
  82. }
  83. if (mode & 1 && inputField) {
  84. inputField.value = stringifyDates(datepicker.dates, config);
  85. }
  86. }
  87. function setDate(datepicker, inputDates, options) {
  88. let {clear, render, autohide} = options;
  89. if (render === undefined) {
  90. render = true;
  91. }
  92. if (!render) {
  93. autohide = false;
  94. } else if (autohide === undefined) {
  95. autohide = datepicker.config.autohide;
  96. }
  97. const newDates = processInputDates(datepicker, inputDates, clear);
  98. if (!newDates) {
  99. return;
  100. }
  101. if (newDates.toString() !== datepicker.dates.toString()) {
  102. datepicker.dates = newDates;
  103. refreshUI(datepicker, render ? 3 : 1);
  104. triggerDatepickerEvent(datepicker, 'changeDate');
  105. } else {
  106. refreshUI(datepicker, 1);
  107. }
  108. if (autohide) {
  109. datepicker.hide();
  110. }
  111. }
  112. /**
  113. * Class representing a date picker
  114. */
  115. export default class Datepicker {
  116. /**
  117. * Create a date picker
  118. * @param {Element} element - element to bind a date picker
  119. * @param {Object} [options] - config options
  120. * @param {DateRangePicker} [rangepicker] - DateRangePicker instance the
  121. * date picker belongs to. Use this only when creating date picker as a part
  122. * of date range picker
  123. */
  124. constructor(element, options = {}, rangepicker = undefined) {
  125. element.datepicker = this;
  126. this.element = element;
  127. // set up config
  128. const config = this.config = Object.assign({
  129. buttonClass: (options.buttonClass && String(options.buttonClass)) || 'button',
  130. container: document.body,
  131. defaultViewDate: today(),
  132. maxDate: undefined,
  133. minDate: undefined,
  134. }, processOptions(defaultOptions, this));
  135. this._options = options;
  136. Object.assign(config, processOptions(options, this));
  137. // configure by type
  138. const inline = this.inline = element.tagName !== 'INPUT';
  139. let inputField;
  140. let initialDates;
  141. if (inline) {
  142. config.container = element;
  143. initialDates = stringToArray(element.dataset.date, config.dateDelimiter);
  144. delete element.dataset.date;
  145. } else {
  146. const container = options.container ? document.querySelector(options.container) : null;
  147. if (container) {
  148. config.container = container;
  149. }
  150. inputField = this.inputField = element;
  151. inputField.classList.add('datepicker-input');
  152. initialDates = stringToArray(inputField.value, config.dateDelimiter);
  153. }
  154. if (rangepicker) {
  155. // check validiry
  156. const index = rangepicker.inputs.indexOf(inputField);
  157. const datepickers = rangepicker.datepickers;
  158. if (index < 0 || index > 1 || !Array.isArray(datepickers)) {
  159. throw Error('Invalid rangepicker object.');
  160. }
  161. // attach itaelf to the rangepicker here so that processInputDates() can
  162. // determine if this is the range-end picker of the rangepicker while
  163. // setting inital values when pickLevel > 0
  164. datepickers[index] = this;
  165. // add getter for rangepicker
  166. Object.defineProperty(this, 'rangepicker', {
  167. get() {
  168. return rangepicker;
  169. },
  170. });
  171. }
  172. // set initial dates
  173. this.dates = [];
  174. // process initial value
  175. const inputDateValues = processInputDates(this, initialDates);
  176. if (inputDateValues && inputDateValues.length > 0) {
  177. this.dates = inputDateValues;
  178. }
  179. if (inputField) {
  180. inputField.value = stringifyDates(this.dates, config);
  181. }
  182. const picker = this.picker = new Picker(this);
  183. if (inline) {
  184. this.show();
  185. } else {
  186. // set up event listeners in other modes
  187. const onMousedownDocument = onClickOutside.bind(null, this);
  188. const listeners = [
  189. [inputField, 'keydown', onKeydown.bind(null, this)],
  190. [inputField, 'focus', onFocus.bind(null, this)],
  191. [inputField, 'mousedown', onMousedown.bind(null, this)],
  192. [inputField, 'click', onClickInput.bind(null, this)],
  193. [inputField, 'paste', onPaste.bind(null, this)],
  194. [document, 'mousedown', onMousedownDocument],
  195. [document, 'touchstart', onMousedownDocument],
  196. [window, 'resize', picker.place.bind(picker)]
  197. ];
  198. registerListeners(this, listeners);
  199. }
  200. }
  201. /**
  202. * Format Date object or time value in given format and language
  203. * @param {Date|Number} date - date or time value to format
  204. * @param {String|Object} format - format string or object that contains
  205. * toDisplay() custom formatter, whose signature is
  206. * - args:
  207. * - date: {Date} - Date instance of the date passed to the method
  208. * - format: {Object} - the format object passed to the method
  209. * - locale: {Object} - locale for the language specified by `lang`
  210. * - return:
  211. * {String} formatted date
  212. * @param {String} [lang=en] - language code for the locale to use
  213. * @return {String} formatted date
  214. */
  215. static formatDate(date, format, lang) {
  216. return formatDate(date, format, lang && locales[lang] || locales.en);
  217. }
  218. /**
  219. * Parse date string
  220. * @param {String|Date|Number} dateStr - date string, Date object or time
  221. * value to parse
  222. * @param {String|Object} format - format string or object that contains
  223. * toValue() custom parser, whose signature is
  224. * - args:
  225. * - dateStr: {String|Date|Number} - the dateStr passed to the method
  226. * - format: {Object} - the format object passed to the method
  227. * - locale: {Object} - locale for the language specified by `lang`
  228. * - return:
  229. * {Date|Number} parsed date or its time value
  230. * @param {String} [lang=en] - language code for the locale to use
  231. * @return {Number} time value of parsed date
  232. */
  233. static parseDate(dateStr, format, lang) {
  234. return parseDate(dateStr, format, lang && locales[lang] || locales.en);
  235. }
  236. /**
  237. * @type {Object} - Installed locales in `[languageCode]: localeObject` format
  238. * en`:_English (US)_ is pre-installed.
  239. */
  240. static get locales() {
  241. return locales;
  242. }
  243. /**
  244. * @type {Boolean} - Whether the picker element is shown. `true` whne shown
  245. */
  246. get active() {
  247. return !!(this.picker && this.picker.active);
  248. }
  249. /**
  250. * @type {HTMLDivElement} - DOM object of picker element
  251. */
  252. get pickerElement() {
  253. return this.picker ? this.picker.element : undefined;
  254. }
  255. /**
  256. * Set new values to the config options
  257. * @param {Object} options - config options to update
  258. */
  259. setOptions(options) {
  260. const picker = this.picker;
  261. const newOptions = processOptions(options, this);
  262. Object.assign(this._options, options);
  263. Object.assign(this.config, newOptions);
  264. picker.setOptions(newOptions);
  265. refreshUI(this, 3);
  266. }
  267. /**
  268. * Show the picker element
  269. */
  270. show() {
  271. if (this.inputField) {
  272. if (this.inputField.disabled) {
  273. return;
  274. }
  275. if (this.inputField !== document.activeElement) {
  276. this._showing = true;
  277. this.inputField.focus();
  278. delete this._showing;
  279. }
  280. }
  281. this.picker.show();
  282. }
  283. /**
  284. * Hide the picker element
  285. * Not available on inline picker
  286. */
  287. hide() {
  288. if (this.inline) {
  289. return;
  290. }
  291. this.picker.hide();
  292. this.picker.update().changeView(this.config.startView).render();
  293. }
  294. /**
  295. * Destroy the Datepicker instance
  296. * @return {Detepicker} - the instance destroyed
  297. */
  298. destroy() {
  299. this.hide();
  300. unregisterListeners(this);
  301. this.picker.detach();
  302. if (!this.inline) {
  303. this.inputField.classList.remove('datepicker-input');
  304. }
  305. delete this.element.datepicker;
  306. return this;
  307. }
  308. /**
  309. * Get the selected date(s)
  310. *
  311. * The method returns a Date object of selected date by default, and returns
  312. * an array of selected dates in multidate mode. If format string is passed,
  313. * it returns date string(s) formatted in given format.
  314. *
  315. * @param {String} [format] - Format string to stringify the date(s)
  316. * @return {Date|String|Date[]|String[]} - selected date(s), or if none is
  317. * selected, empty array in multidate mode and untitled in sigledate mode
  318. */
  319. getDate(format = undefined) {
  320. const callback = format
  321. ? date => formatDate(date, format, this.config.locale)
  322. : date => new Date(date);
  323. if (this.config.multidate) {
  324. return this.dates.map(callback);
  325. }
  326. if (this.dates.length > 0) {
  327. return callback(this.dates[0]);
  328. }
  329. }
  330. /**
  331. * Set selected date(s)
  332. *
  333. * In multidate mode, you can pass multiple dates as a series of arguments
  334. * or an array. (Since each date is parsed individually, the type of the
  335. * dates doesn't have to be the same.)
  336. * The given dates are used to toggle the select status of each date. The
  337. * number of selected dates is kept from exceeding the length set to
  338. * maxNumberOfDates.
  339. *
  340. * With clear: true option, the method can be used to clear the selection
  341. * and to replace the selection instead of toggling in multidate mode.
  342. * If the option is passed with no date arguments or an empty dates array,
  343. * it works as "clear" (clear the selection then set nothing), and if the
  344. * option is passed with new dates to select, it works as "replace" (clear
  345. * the selection then set the given dates)
  346. *
  347. * When render: false option is used, the method omits re-rendering the
  348. * picker element. In this case, you need to call refresh() method later in
  349. * order for the picker element to reflect the changes. The input field is
  350. * refreshed always regardless of this option.
  351. *
  352. * When invalid (unparsable, repeated, disabled or out-of-range) dates are
  353. * passed, the method ignores them and applies only valid ones. In the case
  354. * that all the given dates are invalid, which is distinguished from passing
  355. * no dates, the method considers it as an error and leaves the selection
  356. * untouched.
  357. *
  358. * @param {...(Date|Number|String)|Array} [dates] - Date strings, Date
  359. * objects, time values or mix of those for new selection
  360. * @param {Object} [options] - function options
  361. * - clear: {boolean} - Whether to clear the existing selection
  362. * defualt: false
  363. * - render: {boolean} - Whether to re-render the picker element
  364. * default: true
  365. * - autohide: {boolean} - Whether to hide the picker element after re-render
  366. * Ignored when used with render: false
  367. * default: config.autohide
  368. */
  369. setDate(...args) {
  370. const dates = [...args];
  371. const opts = {};
  372. const lastArg = lastItemOf(args);
  373. if (
  374. typeof lastArg === 'object'
  375. && !Array.isArray(lastArg)
  376. && !(lastArg instanceof Date)
  377. && lastArg
  378. ) {
  379. Object.assign(opts, dates.pop());
  380. }
  381. const inputDates = Array.isArray(dates[0]) ? dates[0] : dates;
  382. setDate(this, inputDates, opts);
  383. }
  384. /**
  385. * Update the selected date(s) with input field's value
  386. * Not available on inline picker
  387. *
  388. * The input field will be refreshed with properly formatted date string.
  389. *
  390. * @param {Object} [options] - function options
  391. * - autohide: {boolean} - whether to hide the picker element after refresh
  392. * default: false
  393. */
  394. update(options = undefined) {
  395. if (this.inline) {
  396. return;
  397. }
  398. const opts = {clear: true, autohide: !!(options && options.autohide)};
  399. const inputDates = stringToArray(this.inputField.value, this.config.dateDelimiter);
  400. setDate(this, inputDates, opts);
  401. }
  402. /**
  403. * Refresh the picker element and the associated input field
  404. * @param {String} [target] - target item when refreshing one item only
  405. * 'picker' or 'input'
  406. * @param {Boolean} [forceRender] - whether to re-render the picker element
  407. * regardless of its state instead of optimized refresh
  408. */
  409. refresh(target = undefined, forceRender = false) {
  410. if (target && typeof target !== 'string') {
  411. forceRender = target;
  412. target = undefined;
  413. }
  414. let mode;
  415. if (target === 'picker') {
  416. mode = 2;
  417. } else if (target === 'input') {
  418. mode = 1;
  419. } else {
  420. mode = 3;
  421. }
  422. refreshUI(this, mode, !forceRender);
  423. }
  424. /**
  425. * Enter edit mode
  426. * Not available on inline picker or when the picker element is hidden
  427. */
  428. enterEditMode() {
  429. if (this.inline || !this.picker.active || this.editMode) {
  430. return;
  431. }
  432. this.editMode = true;
  433. this.inputField.classList.add('in-edit');
  434. }
  435. /**
  436. * Exit from edit mode
  437. * Not available on inline picker
  438. * @param {Object} [options] - function options
  439. * - update: {boolean} - whether to call update() after exiting
  440. * If false, input field is revert to the existing selection
  441. * default: false
  442. */
  443. exitEditMode(options = undefined) {
  444. if (this.inline || !this.editMode) {
  445. return;
  446. }
  447. const opts = Object.assign({update: false}, options);
  448. delete this.editMode;
  449. this.inputField.classList.remove('in-edit');
  450. if (opts.update) {
  451. this.update(opts);
  452. }
  453. }
  454. }