-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathdatepicker.js
853 lines (732 loc) · 25.1 KB
/
datepicker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
import Base from './base'
import Preset from './preset'
import Renderer from './renderer'
import EventManager from './eventManager'
import DateArray from './util/dateArray'
import DateRangePicker from './dateRangePicker'
import {JQUERY_NAME, Data, Event, Selector, ClassName, Unit, View} from './constants'
import Popper from 'popper.js'
import moment from 'moment'
import extend from 'extend'
/**
* Datepicker for fields using momentjs for all date-based functionality.
*
* Internal dates are stored as UTC moments. To use them in local time, execute moment.local() prior to formatting.
*/
const Datepicker = (($) => {
const JQUERY_NO_CONFLICT = $.fn[JQUERY_NAME]
const Default = {
// lang defaults to en, most i18n comes from moment's locales.
lang: 'en',
// i18n - for the very few strings we use.
i18n: {
en: {
today: 'Today',
clear: 'Clear',
cancel: 'Cancel',
ok: 'Ok'
}
},
button: {
today: false, // If true, displays a “Today” button at the bottom of the datepicker to select the current date
clear: false,
cancel: false,
ok: false
},
autoclose: false, // Whether or not to close the datepicker immediately when a date is selected
keyboard: {
navigation: true, // allow date navigation by arrow keys
touch: true // false will disable keyboard on mobile devices
},
rtl: false,
enableOnReadonly: true, // If false the datepicker will not show on a readonly datepicker field
showOnFocus: true, // If false, the datepicker will be prevented from showing when the input field associated with it receives focus
label: {
title: undefined // string that will appear on top of the datepicker. Some templates do not have a position for title.
},
//-----------------
// view types:
// days | months | years | decades | centuries
view: {
start: 'days', // The view that the datepicker should show when it is opened
min: 'days', // Set a minimum limit for the view mode
max: 'centuries', // Set a maximum limit for the view mode
disabled: [], // Any view disabled will be skipped on #changeView
modes: [
{
cssClass: ClassName.DAYS,
navStep: 1
},
{
cssClass: ClassName.MONTHS,
navStep: 1
},
{
cssClass: ClassName.YEARS,
navStep: 10
},
{
cssClass: ClassName.DECADES,
navStep: 100
},
{
cssClass: ClassName.CENTURIES,
navStep: 1000
}]
},
week: {
start: 0 // Day of the week start. 0 (Sunday) to 6 (Saturday)
// end is calculated based on start
},
// format: // pass in a momentjs compatible format, or it will default to L based on locale
date: {
//start: default: beginning of time - The earliest date that may be selected all earlier dates will be disabled.
//end: default: end of time - The latest date that may be selected all later dates will be disabled
disabled: [], // Single or Array of disabled dates - can be string or moment
//'default': // default is today - can be a string or a moment
toggle: false, // If true, selecting the currently active date will unset the respective date (same as multi-date behavior)
// -----------
// multi-dates
count: 1, // // 2 or more will enable multidate picking. Each date in month view acts as a toggle button, keeping track of which dates the user has selected in order. If a number is given, the picker will limit how many dates can be selected to that number, dropping the oldest dates from the list when the number is exceeded. true equates to no limit. The input’s value (if present) is set to a string generated by joining the dates, formatted, with multidate.separator
separator: ',' // Separator for multiple dates when generating the input value
},
daysOfWeek: {
// Values are 0 (Sunday) to 6 (Saturday)
disabled: [], // Days of the week that should be disabled. Example: disable weekends: [0,6]
highlighted: [] // Days of the week that should be highlighted. Example: highlight weekends: [0,6].
},
// Popper.js options - see https://popper.js.org/
popper: {
// any popper.js options are valid here and will be passed to that component
// placement: 'right',
placement: 'bottom-start',
// flipBehavior: ['bottom-start', 'top-start'],
removeOnDestroy: true
},
//template: undefined, // if undefined - will use new BaseTemplate().createTemplate()
// -------------------
// callbacks TODO: better way to do this?
/*
A function that takes a date as a parameter and returns one of the following values:
- undefined to have no effect
- An object with the following properties:
disabled: A Boolean, indicating whether or not this date is disabled
classes: A String representing additional CSS classes to apply to the date’s cell
tooltip: A tooltip to apply to this date, via the title HTML attribute
*/
beforeShowDay: undefined,
beforeShowMonth: undefined,
beforeShowYear: undefined,
beforeShowDecade: undefined,
beforeShowCentury: undefined
}
/**
* ------------------------------------------------------------------------
* Class Definition
* ------------------------------------------------------------------------
* TODO: break this into components - ConfigurationManager(? not sure on this one), DateManager, EventManager, Renderer?
*/
class Datepicker extends Base {
constructor($element, ...configs) {
super(Default, Preset.resolve(...configs))
this.$element = $element
this.shown = false
this.dates = null //new DateArray() no need to init, #update will init initial round
// get our own utc instance and configure the locale
this.moment = this.newMoment()
// disallow updates during setup, call after
this.allowUpdate = false
// normalize options that are flexible
this.normalizeConfig()
//
this.view = this.config.view.start
// inline datepicker if target is a div
if (this.$element.is('div')) {
this.isInline = true
}
// find the $input right now
else if (this.$element.is('input')) {
this.$input = this.$element
}
else {
throw new Error(`Target element[${this.$element[0].localName}] is neither a div(inline) nor an input.`)
}
// FIXME: data-datepicker-toggle='#input-id' or whatever pattern bootstrap uses for toggle - `click: () => this.show()` instead of old `component` or add-on
// initialize the renderer and create the $picker element
this.renderer = new Renderer(this)
// initialize the EventManager
this.eventManager = new EventManager(this)
// turn back on updates
this.allowUpdate = true
this.update()
this.showView()
if (this.isInline) {
this.show()
}
}
dispose(dataKey = Data.KEY) {
this.hide()
this.eventManager.dispose()
this.renderer.dispose()
this.eventManager = undefined
this.renderer = undefined
this.popper = undefined
super.dispose(dataKey)
}
/**
* @returns a new UTC moment configured with the locale
*/
newMoment(...args) {
let m = null
if (args.length < 1) {
// if no args, use the current date/time (cannot pass in null otherwise time is zeroed)
m = moment()
}
else {
m = moment(...args)
}
m.utc()
m.locale(this.config.lang)
return m
}
/**
* @returns - array of UTC moments selected
*/
getDates() {
// Depending on the show/hide state when called, this.dates may or may not be populated.
// Use it if populated (i.e. initial #update before show), not based on #isShowing
return (this.dates ? this.dates.array : undefined) || this.parseDateArrayFromInput()
}
/**
* Determine the viewDate and constrain by the configuration - no side effects
*
* NOTE: this.viewDate is null after hidden, and this methoud is used by #update to redetermine a new value.
* The result of this method is explicitly not cached, if you want the cached value during a normal
* internal operation, you should be using the `this.viewDate` set by #update
* @param fallbackToDefaults - resolve the date first, if not found, fallback to the default config.date.start
* @returns - the latest UTC moment selected
*/
getDate(fallbackToDefaults = false) {
// Depending on the show/hide state when called, this.dates may or may not be populated.
// Use it if populated (i.e. initial #update before show), not based on #isShowing
let dateArray = this.getDates()
if (dateArray.length) {
// return the last date in the array (go backwards 1 index)
return dateArray.slice(-1)[0].clone()
}
// if not found above and not to be resolved by defaults, null
if (!fallbackToDefaults) {
return null
}
// resolve based on the defaults
if (this.viewDate < this.config.date.start) {
return this.config.date.start.clone()
}
else if (this.viewDate > this.config.date.end) {
return this.config.date.end.clone()
}
else {
return this.config.date.default.clone()
}
}
updateMultidateOrToggle(viewDate) {
// if multidate is not enabled && and toggle is not true, just update and get out.
if (this.config.date.count < 2 && this.config.date.toggle !== true) {
this.update(viewDate)
return
}
// If nothing passed in, we are clearing all dates
if (!viewDate) {
this.update(null)
return
}
//------------
// Multidate enabled
//------------
// We need to operate on a temporary date array, passed to update
let newDates = this.dates.copy()
let index = newDates.contains(viewDate)
// first check toggle off on a date
if (index !== -1) {
newDates.remove(index)
}
// if not a toggle, it's a new date
else {
newDates.push(viewDate)
}
// constrain the date count by the limit, removing the first
while (newDates.length() > this.config.date.count) {
newDates.remove(0)
}
// finally call update with the new dates
if (newDates.length() === 0) {
// if length is 0, pass null to reset the internal dates, otherwise it will look at/parse input
this.update(null)
}
else {
this.update(...newDates.array)
}
}
/**
* Any call stack resulting here means that we are selecting a new date (or dates) and re-rendering.
*
*
* @param momentsOrStrings - one or more - String|moment - optional. null will clear dates, nothing or empty will resolve dates.
* @returns {Datepicker}
*/
update(...momentsOrStrings) {
if (!this.allowUpdate) {
return this
}
// parse dates and get out if there is no diff
let newDates = this.configureNewDateArray(...momentsOrStrings)
if (newDates.isSame(this.dates)) {
this.debug('no update needed, dates are the same')
return
}
// there is a change
this.dates = newDates
// resolve the new viewDate constrained by the configuration
this.viewDate = this.getDate(true)
// set the input value
this.$input.val(this.getDateFormatted())
// re-render the element
this.renderer.render()
// fire the date change
this.eventManager.trigger(Event.DATE_CHANGE)
// fire change on the input to be sure other plugins see it (i.e. validation)
this.$input.change()
// If on the day view && autoclose is enabled - hide
if (this.view === View.DAYS && this.config.autoclose) {
this.hide()
}
return this
}
/**
* Sets a new lower date limit on the datepicker.
* Omit (or provide an otherwise falsey value) to unset the limit.
* @param dateStart
* @returns {Datepicker}
*/
setDateStart(dateStart) {
if (dateStart) {
// verify/reparse
this.config.date.start = this.parseDate(dateStart)
}
else {
// default to beginning of time
this.config.date.start = this.startOfAllTime()
}
// called from #normalizeConfig
this.update()
return this
}
/**
* Sets a new upper date limit on the datepicker.
* Omit (or provide an otherwise falsey value) to unset the limit.
* @param dateEnd
* @returns {Datepicker}
*/
setDateEnd(dateEnd) {
if (dateEnd) {
// verify/reparse
this.config.date.end = this.parseDate(dateEnd)
}
else {
// default to beginning of time
this.config.date.end = this.endOfAllTime()
}
// called from #normalizeConfig
this.update()
return this
}
/**
* Sets the days that should be disabled
* Omit (or provide an otherwise falsey value) to unset.
* @param dates - String|Moment|Array of String|Moment
* @returns {Datepicker}
*/
setDatesDisabled(dates) {
let dateArray = dates
// Disabled dates
if (!Array.isArray(dateArray)) {
dateArray = [dateArray]
}
let newDisabled = []
for (let d of dateArray) {
newDisabled.push(this.parseDate(d))
}
this.config.date.disabled = newDisabled
// called from #normalizeConfig
this.update()
return this
}
/**
* Sets the days of week that should be disabled. See config.daysOfWeek.disabled
* Omit (or provide an otherwise falsey value) to unset.
* @param days
* @returns {Datepicker}
*/
setDaysOfWeekDisabled(days) {
this.config.daysOfWeek.disabled = days
this.normalizeConfig()
this.update()
return this
}
/**
* Sets the days of week that should be highlighted. See config.daysOfWeek.highlighted
* Omit (or provide an otherwise falsey value) to unset.
* @param days
* @returns {Datepicker}
*/
setDaysOfWeekHighlighted(days) {
this.config.daysOfWeek.highlighted = days
this.normalizeConfig()
this.update()
return this
}
// ------------------------------------------------------------------------
// protected
/**
*
* @param range - a {DateRange} from moment-range - provide a falsey value to unset
*/
setRange(range) {
this.range = range
this.renderer.render();
}
// ------------------------------------------------------------------------
// private
/**
* Change view given the direction. If past the bottom, it will #hide
* @param direction
*/
changeView(direction) {
if (direction < 0 && this.view === View.DAYS) {
this.hide()
}
else {
let nextView = this.boundedView(this.view + direction)
if (this.config.view.disabled.includes(nextView)) {
// determine general direction
let skipDisabledDirection = (direction < 1) ? -1 : 1
this.changeView(direction + skipDisabledDirection)
}
else {
this.showView(nextView)
}
}
}
/**
* Get a view within the bounds of min/max
* @param view
* @returns {number}
*/
boundedView(view) {
return Math.max(this.config.view.min, Math.min(this.config.view.max, view))
}
/**
* Show a specific view by id.
* @param viewId
*/
showView(viewId = this.view) {
this.view = viewId
this.renderer.showView(this.view)
}
/**
*
* @param date - start date
* @param dir - direction/number of units
* @param unit - day|month|year etc to use with moment#add
* @returns {*}
*/
moveAvailableDate(date, dir, unit) {
let m = date.clone()
do {
m = m.add(dir, unit)
if (!this.boundedDate(m))
return false
unit = Unit.DAY
}
while (this.dateIsDisabled(m))
return m
}
isShowing() {
return this.shown
}
//
show() {
if (this.isInline || this.isShowing()) {
return
}
if (this.$input.attr('readonly') && this.config.enableOnReadonly === false) {
return
}
// re-read the dates to populate internal state
this.update()
// popper
this.popper = new Popper(this.$element, {
contentType: 'node',
content: this.renderer.$picker,
parent: this.$element.parent()[0]
},
extend({}, true, {boundariesElement: this.$element}, this.config.popper)
)
this.shown = true
this.eventManager.onShown()
return this
}
hide() {
if (this.isInline || !this.isShowing()) {
return this
}
// on hide, always do the same resets
this.viewDate = this.dates = null
// popper
this.popper.destroy()
this.popper = undefined
this.shown = false
this.eventManager.onHidden()
// reset the view
this.showView(this.config.view.start)
return this
}
normalizeConfig() {
// disallow updates - must call #update after
let originalAllowUpdate = this.allowUpdate
this.allowUpdate = false
// Normalize views as view-type integers
this.config.view.start = this.resolveViewType(this.config.view.start)
this.config.view.min = this.resolveViewType(this.config.view.min)
this.config.view.max = this.resolveViewType(this.config.view.max) // default to years (slightly different than other view resolution)
let disabledViews = this.config.view.disabled
if (!Array.isArray(disabledViews)) {
disabledViews = [disabledViews]
}
this.config.view.disabled = []
for (let disabledView of disabledViews) {
this.config.view.disabled.push(this.resolveViewType(disabledView))
}
// Check that the start view is between min and max
this.config.view.start = Math.min(this.config.view.start, this.config.view.max)
this.config.view.start = Math.max(this.config.view.start, this.config.view.min)
// Week
this.config.week.start %= 7
this.config.week.end = (this.config.week.start + 6) % 7
// Format - setup the format or default to a momentjs format
this.config.format = this.config.format || this.moment.localeData().longDateFormat('L')
// Start/End or Min/max dates
this.setDateStart(this.config.date.start)
this.setDateEnd(this.config.date.end)
this.setDatesDisabled(this.config.date.disabled)
// Default date - if unspecified, it is now
this.config.date.default = this.parseDate(this.config.date.default || this.moment.clone())
// restore allowUpdate
this.allowUpdate = originalAllowUpdate
}
formatDate(mom, format = this.config.format) {
return mom.format(format)
}
parseDates(...dates) {
//if(!dates || dates.length < 1){
// return []
//}
let results = []
for (let date of dates) {
if (date) {
results.push(this.parseDate(date))
}
}
return results
}
parseDate(value, format = this.config.format) {
// @see http://momentjs.com/docs/#/parsing/
// return any current moment
if (moment.isMoment(value)) {
if (!value.isValid()) {
this.throwError(`Invalid moment: ${value} provided.`)
}
return this.newMoment(value)
}
else if (typeof value === "string") {
// parse with locale and strictness
let m = moment(value, format, this.config.lang, true)
if (!m.isValid()) {
this.throwError(`Invalid moment: ${value} for format: ${format} and locale: ${this.config.lang}`)
}
return m
}
else {
this.throwError(`Unknown value type ${typeof value} for value: ${this.dump(value)}`)
}
}
shouldBeHighlighted(date) {
return $.inArray(date.day(), this.config.daysOfWeek.highlighted) !== -1
}
weekOfDateIsDisabled(date) {
return $.inArray(date.day(), this.config.daysOfWeek.disabled) !== -1
}
dateIsDisabled(date) {
return (
this.weekOfDateIsDisabled(date) ||
$.grep(this.config.date.disabled,
(d) => {
return date.isSame(d, Unit.DAY)
}
).length > 0
)
}
boundedDate(date) {
return date.isSameOrAfter(this.config.date.start) && date.isSameOrBefore(this.config.date.end)
}
boundedDates(...dates) {
return $.grep(dates, (date) => {
return (!this.boundedDate(date) || !date)
}, true)
}
startOfDay(moment = this.moment) {
return moment.clone().startOf(Unit.DAY)
}
startOfAllTime(moment = this.moment) {
return moment.clone().startOf(Unit.YEAR).year(0)
}
endOfAllTime(moment = this.moment) {
return moment.clone().endOf(Unit.YEAR).year(9999) // ?? better value to set for this?
}
resolveViewType(view) {
if (typeof view === 'string') {
let value = null
switch (view) {
case 'days':
value = View.DAYS
break
case 'months':
value = View.MONTHS
break
case 'years':
value = View.YEARS
break
case 'decades':
value = View.DECADES
break
case 'centuries':
value = View.CENTURIES
break
default:
throw new Error(`Unknown view type '${view}'. Try one of: days | months | years | decades | centuries`)
}
return value
}
else {
return view
}
}
clearDates() {
this.update(null)
}
getDateFormatted(format = this.config.format) {
return this.dates.formattedArray(format).join(this.config.date.separator)
}
/**
* resolve a new {DateArray}
*
* @param dates
* @returns {DateArray}
*/
configureNewDateArray(...dates) {
if (dates.length > 0) {
let newDatesArray = this.parseDates(...dates)
newDatesArray = this.boundedDates(...newDatesArray)
return new DateArray(...newDatesArray)
}
else {
return new DateArray(...this.parseDateArrayFromInput())
// already checks dates inside #parseDatesFromInput
}
}
/**
* @returns - array of UTC moments
*/
parseDateArrayFromInput() {
let value = this.$input.val()
let dates
if (value && this.config.date.count > 1) {
dates = value.split(this.config.date.separator)
}
else {
dates = [value]
}
dates = this.parseDates(...dates)
dates = this.boundedDates(...dates)
return dates
}
// ------------------------------------------------------------------------
// static
static _jQueryInterface(config) {
//let methodResult = undefined
return this.each(
function () {
let $element = $(this)
let data = $element.data(Data.KEY)
// Options priority: js args, data-attrs
let _config = $.extend(
{},
$element.data(),
typeof config === 'object' && config // config could be a string method name.
)
// instantiate a Datepicker or a DateRangePicker
if (!data) {
// FIXME: I really think this should be encapsulated in DateRangePicker, and not here.
if ($element.hasClass('input-daterange') || _config.inputs) {
data = new DateRangePicker($element,
$.extend(_config, {inputs: _config.inputs || $element.find('input').toArray()})
)
}
else {
data = new Datepicker($element, _config)
}
$element.data(Data.KEY, data)
}
// call public methods jquery style
if (typeof config === 'string') {
if (data[config] === undefined) {
throw new Error(`No method named "${config}"`)
}
//methodResult =
data[config]()
}
}
)
//if (methodResult !== undefined) {
// // return method result if there is one
// return methodResult
//}
//else {
// // return the element
// return this
//}
}
}
/**
* ------------------------------------------------------------------------
* Data Api implementation
* ------------------------------------------------------------------------
*/
$(document).on(Event.CLICK_DATA_API, Selector.DATA_PROVIDE, function (event) {
event.preventDefault()
Datepicker._jQueryInterface.call(this, 'show')
})
/**
* ------------------------------------------------------------------------
* jQuery
* ------------------------------------------------------------------------
*/
$.fn[JQUERY_NAME] = Datepicker._jQueryInterface
$.fn[JQUERY_NAME].Constructor = Datepicker
$.fn[JQUERY_NAME].noConflict = () => {
$.fn[JQUERY_NAME] = JQUERY_NO_CONFLICT
return Datepicker._jQueryInterface
}
return Datepicker
})(jQuery)
export default Datepicker