@vaadin/date-picker 24.2.0-dev.f254716fe → 24.2.0-rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1032 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2016 - 2023 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import { flush } from '@polymer/polymer/lib/utils/flush.js';
7
+ import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js';
8
+ import { timeOut } from '@vaadin/component-base/src/async.js';
9
+ import { Debouncer } from '@vaadin/component-base/src/debounce.js';
10
+ import { addListener, setTouchAction } from '@vaadin/component-base/src/gestures.js';
11
+ import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js';
12
+ import { SlotController } from '@vaadin/component-base/src/slot-controller.js';
13
+ import { dateAfterXMonths, dateEquals, extractDateParts, getClosestDate } from './vaadin-date-picker-helper.js';
14
+
15
+ /**
16
+ * @polymerMixin
17
+ */
18
+ export const DatePickerOverlayContentMixin = (superClass) =>
19
+ class DatePickerOverlayContentMixin extends superClass {
20
+ static get properties() {
21
+ return {
22
+ scrollDuration: {
23
+ type: Number,
24
+ value: 300,
25
+ },
26
+
27
+ /**
28
+ * The value for this element.
29
+ */
30
+ selectedDate: {
31
+ type: Object,
32
+ value: null,
33
+ sync: true,
34
+ },
35
+
36
+ /**
37
+ * Date value which is focused using keyboard.
38
+ */
39
+ focusedDate: {
40
+ type: Object,
41
+ notify: true,
42
+ observer: '_focusedDateChanged',
43
+ sync: true,
44
+ },
45
+
46
+ _focusedMonthDate: Number,
47
+
48
+ /**
49
+ * Date which should be visible when there is no value selected.
50
+ */
51
+ initialPosition: {
52
+ type: Object,
53
+ observer: '_initialPositionChanged',
54
+ },
55
+
56
+ _originDate: {
57
+ type: Object,
58
+ value: new Date(),
59
+ },
60
+
61
+ _visibleMonthIndex: Number,
62
+
63
+ _desktopMode: {
64
+ type: Boolean,
65
+ observer: '_desktopModeChanged',
66
+ },
67
+
68
+ _desktopMediaQuery: {
69
+ type: String,
70
+ value: '(min-width: 375px)',
71
+ },
72
+
73
+ _translateX: {
74
+ observer: '_translateXChanged',
75
+ },
76
+
77
+ _yearScrollerWidth: {
78
+ value: 50,
79
+ },
80
+
81
+ i18n: {
82
+ type: Object,
83
+ },
84
+
85
+ showWeekNumbers: {
86
+ type: Boolean,
87
+ value: false,
88
+ },
89
+
90
+ _ignoreTaps: Boolean,
91
+
92
+ _notTapping: Boolean,
93
+
94
+ /**
95
+ * The earliest date that can be selected. All earlier dates will be disabled.
96
+ */
97
+ minDate: {
98
+ type: Object,
99
+ sync: true,
100
+ },
101
+
102
+ /**
103
+ * The latest date that can be selected. All later dates will be disabled.
104
+ */
105
+ maxDate: {
106
+ type: Object,
107
+ sync: true,
108
+ },
109
+
110
+ /**
111
+ * Input label
112
+ */
113
+ label: String,
114
+
115
+ _cancelButton: {
116
+ type: Object,
117
+ },
118
+
119
+ _todayButton: {
120
+ type: Object,
121
+ },
122
+
123
+ calendars: {
124
+ type: Array,
125
+ value: () => [],
126
+ },
127
+
128
+ years: {
129
+ type: Array,
130
+ value: () => [],
131
+ },
132
+ };
133
+ }
134
+
135
+ static get observers() {
136
+ return [
137
+ '__updateCalendars(calendars, i18n, minDate, maxDate, selectedDate, focusedDate, showWeekNumbers, _ignoreTaps, _theme)',
138
+ '__updateCancelButton(_cancelButton, i18n)',
139
+ '__updateTodayButton(_todayButton, i18n, minDate, maxDate)',
140
+ '__updateYears(years, selectedDate, _theme)',
141
+ ];
142
+ }
143
+
144
+ /**
145
+ * Whether to scroll to a sub-month position when scrolling to a date.
146
+ * This is active if the month scroller is not large enough to fit a
147
+ * full month. In that case we want to scroll to a position between
148
+ * two months in order to have the focused date in the visible area.
149
+ * @returns {boolean} whether to use sub-month scrolling
150
+ * @private
151
+ */
152
+ get __useSubMonthScrolling() {
153
+ return this._monthScroller.clientHeight < this._monthScroller.itemHeight + this._monthScroller.bufferOffset;
154
+ }
155
+
156
+ get focusableDateElement() {
157
+ return this.calendars.map((calendar) => calendar.focusableDateElement).find(Boolean);
158
+ }
159
+
160
+ /** @protected */
161
+ _addListeners() {
162
+ setTouchAction(this.$.scrollers, 'pan-y');
163
+
164
+ addListener(this.$.scrollers, 'track', this._track.bind(this));
165
+ addListener(this.shadowRoot.querySelector('[part="clear-button"]'), 'tap', this._clear.bind(this));
166
+ addListener(this.shadowRoot.querySelector('[part="toggle-button"]'), 'tap', this._cancel.bind(this));
167
+ addListener(
168
+ this.shadowRoot.querySelector('[part="years-toggle-button"]'),
169
+ 'tap',
170
+ this._toggleYearScroller.bind(this),
171
+ );
172
+ }
173
+
174
+ /** @protected */
175
+ _initControllers() {
176
+ this.addController(
177
+ new MediaQueryController(this._desktopMediaQuery, (matches) => {
178
+ this._desktopMode = matches;
179
+ }),
180
+ );
181
+
182
+ this.addController(
183
+ new SlotController(this, 'today-button', 'vaadin-button', {
184
+ observe: false,
185
+ initializer: (btn) => {
186
+ btn.setAttribute('theme', 'tertiary');
187
+ btn.addEventListener('keydown', (e) => this.__onTodayButtonKeyDown(e));
188
+ addListener(btn, 'tap', this._onTodayTap.bind(this));
189
+ this._todayButton = btn;
190
+ },
191
+ }),
192
+ );
193
+
194
+ this.addController(
195
+ new SlotController(this, 'cancel-button', 'vaadin-button', {
196
+ observe: false,
197
+ initializer: (btn) => {
198
+ btn.setAttribute('theme', 'tertiary');
199
+ btn.addEventListener('keydown', (e) => this.__onCancelButtonKeyDown(e));
200
+ addListener(btn, 'tap', this._cancel.bind(this));
201
+ this._cancelButton = btn;
202
+ },
203
+ }),
204
+ );
205
+
206
+ this.__initMonthScroller();
207
+ this.__initYearScroller();
208
+ }
209
+
210
+ reset() {
211
+ this._closeYearScroller();
212
+ this._toggleAnimateClass(true);
213
+ }
214
+
215
+ /**
216
+ * Focuses the cancel button
217
+ */
218
+ focusCancel() {
219
+ this._cancelButton.focus();
220
+ }
221
+
222
+ /**
223
+ * Scrolls the list to the given Date.
224
+ */
225
+ scrollToDate(date, animate) {
226
+ const offset = this.__useSubMonthScrolling ? this._calculateWeekScrollOffset(date) : 0;
227
+ this._scrollToPosition(this._differenceInMonths(date, this._originDate) + offset, animate);
228
+ this._monthScroller.forceUpdate();
229
+ }
230
+
231
+ /** @private */
232
+ __initMonthScroller() {
233
+ this.addController(
234
+ new SlotController(this, 'months', 'vaadin-date-picker-month-scroller', {
235
+ observe: false,
236
+ initializer: (scroller) => {
237
+ scroller.addEventListener('custom-scroll', () => {
238
+ this._onMonthScroll();
239
+ });
240
+
241
+ scroller.addEventListener('touchstart', () => {
242
+ this._onMonthScrollTouchStart();
243
+ });
244
+
245
+ scroller.addEventListener('keydown', (e) => {
246
+ this.__onMonthCalendarKeyDown(e);
247
+ });
248
+
249
+ scroller.addEventListener('init-done', () => {
250
+ const calendars = [...this.querySelectorAll('vaadin-month-calendar')];
251
+
252
+ // Two-way binding for selectedDate property
253
+ calendars.forEach((calendar) => {
254
+ calendar.addEventListener('selected-date-changed', (e) => {
255
+ this.selectedDate = e.detail.value;
256
+ });
257
+ });
258
+
259
+ this.calendars = calendars;
260
+ });
261
+
262
+ this._monthScroller = scroller;
263
+ },
264
+ }),
265
+ );
266
+ }
267
+
268
+ /** @private */
269
+ __initYearScroller() {
270
+ this.addController(
271
+ new SlotController(this, 'years', 'vaadin-date-picker-year-scroller', {
272
+ observe: false,
273
+ initializer: (scroller) => {
274
+ scroller.setAttribute('aria-hidden', 'true');
275
+
276
+ addListener(scroller, 'tap', (e) => {
277
+ this._onYearTap(e);
278
+ });
279
+
280
+ scroller.addEventListener('custom-scroll', () => {
281
+ this._onYearScroll();
282
+ });
283
+
284
+ scroller.addEventListener('touchstart', () => {
285
+ this._onYearScrollTouchStart();
286
+ });
287
+
288
+ scroller.addEventListener('init-done', () => {
289
+ this.years = [...this.querySelectorAll('vaadin-date-picker-year')];
290
+ });
291
+
292
+ this._yearScroller = scroller;
293
+ },
294
+ }),
295
+ );
296
+ }
297
+
298
+ /** @private */
299
+ __updateCancelButton(cancelButton, i18n) {
300
+ if (cancelButton) {
301
+ cancelButton.textContent = i18n && i18n.cancel;
302
+ }
303
+ }
304
+
305
+ /** @private */
306
+ __updateTodayButton(todayButton, i18n, minDate, maxDate) {
307
+ if (todayButton) {
308
+ todayButton.textContent = i18n && i18n.today;
309
+ todayButton.disabled = !this._isTodayAllowed(minDate, maxDate);
310
+ }
311
+ }
312
+
313
+ // eslint-disable-next-line max-params
314
+ __updateCalendars(
315
+ calendars,
316
+ i18n,
317
+ minDate,
318
+ maxDate,
319
+ selectedDate,
320
+ focusedDate,
321
+ showWeekNumbers,
322
+ ignoreTaps,
323
+ theme,
324
+ ) {
325
+ if (calendars && calendars.length) {
326
+ calendars.forEach((calendar) => {
327
+ calendar.i18n = i18n;
328
+ calendar.minDate = minDate;
329
+ calendar.maxDate = maxDate;
330
+ calendar.focusedDate = focusedDate;
331
+ calendar.selectedDate = selectedDate;
332
+ calendar.showWeekNumbers = showWeekNumbers;
333
+ calendar.ignoreTaps = ignoreTaps;
334
+
335
+ if (theme) {
336
+ calendar.setAttribute('theme', theme);
337
+ } else {
338
+ calendar.removeAttribute('theme');
339
+ }
340
+ });
341
+ }
342
+ }
343
+
344
+ /** @private */
345
+ __updateYears(years, selectedDate, theme) {
346
+ if (years && years.length) {
347
+ years.forEach((year) => {
348
+ year.selectedDate = selectedDate;
349
+
350
+ if (theme) {
351
+ year.setAttribute('theme', theme);
352
+ } else {
353
+ year.removeAttribute('theme');
354
+ }
355
+ });
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Select a date and fire event indicating user interaction.
361
+ * @protected
362
+ */
363
+ _selectDate(dateToSelect) {
364
+ this.selectedDate = dateToSelect;
365
+ this.dispatchEvent(
366
+ new CustomEvent('date-selected', { detail: { date: dateToSelect }, bubbles: true, composed: true }),
367
+ );
368
+ }
369
+
370
+ /** @private */
371
+ _desktopModeChanged(desktopMode) {
372
+ this.toggleAttribute('desktop', desktopMode);
373
+ }
374
+
375
+ /** @private */
376
+ _focusedDateChanged(focusedDate) {
377
+ this.revealDate(focusedDate);
378
+ }
379
+
380
+ /**
381
+ * Scrolls the month and year scrollers enough to reveal the given date.
382
+ */
383
+ revealDate(date, animate = true) {
384
+ if (!date) {
385
+ return;
386
+ }
387
+ const diff = this._differenceInMonths(date, this._originDate);
388
+ // If scroll area does not fit the full month, then always scroll with an offset to
389
+ // approximately display the week of the date
390
+ if (this.__useSubMonthScrolling) {
391
+ const offset = this._calculateWeekScrollOffset(date);
392
+ this._scrollToPosition(diff + offset, animate);
393
+ return;
394
+ }
395
+
396
+ // Otherwise determine if we need to scroll to make the month of the date visible
397
+ const scrolledAboveViewport = this._monthScroller.position > diff;
398
+
399
+ const visibleArea = Math.max(
400
+ this._monthScroller.itemHeight,
401
+ this._monthScroller.clientHeight - this._monthScroller.bufferOffset * 2,
402
+ );
403
+ const visibleItems = visibleArea / this._monthScroller.itemHeight;
404
+ const scrolledBelowViewport = this._monthScroller.position + visibleItems - 1 < diff;
405
+
406
+ if (scrolledAboveViewport) {
407
+ this._scrollToPosition(diff, animate);
408
+ } else if (scrolledBelowViewport) {
409
+ this._scrollToPosition(diff - visibleItems + 1, animate);
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Calculates an offset to be added to the month scroll position
415
+ * when using sub-month scrolling, in order ensure that the week
416
+ * that the date is in is visible even for small scroll areas.
417
+ * As the month scroller uses a month as minimal scroll unit
418
+ * (a value of `1` equals one month), we can not exactly identify
419
+ * the position of a specific week. This is a best effort
420
+ * implementation based on manual testing.
421
+ * @param date the date for which to calculate the offset
422
+ * @returns {number} the offset
423
+ * @private
424
+ */
425
+ _calculateWeekScrollOffset(date) {
426
+ // Get first day of month
427
+ const temp = new Date(0, 0);
428
+ temp.setFullYear(date.getFullYear());
429
+ temp.setMonth(date.getMonth());
430
+ temp.setDate(1);
431
+ // Determine week (=row index) of date within the month
432
+ let week = 0;
433
+ while (temp.getDate() < date.getDate()) {
434
+ temp.setDate(temp.getDate() + 1);
435
+ if (temp.getDay() === this.i18n.firstDayOfWeek) {
436
+ week += 1;
437
+ }
438
+ }
439
+ // Calculate magic number that approximately keeps the week visible
440
+ return week / 6;
441
+ }
442
+
443
+ /** @private */
444
+ _initialPositionChanged(initialPosition) {
445
+ if (this._monthScroller && this._yearScroller) {
446
+ this._monthScroller.active = true;
447
+ this._yearScroller.active = true;
448
+ }
449
+
450
+ this.scrollToDate(initialPosition);
451
+ }
452
+
453
+ /** @private */
454
+ _repositionYearScroller() {
455
+ const monthPosition = this._monthScroller.position;
456
+ this._visibleMonthIndex = Math.floor(monthPosition);
457
+ this._yearScroller.position = (monthPosition + this._originDate.getMonth()) / 12;
458
+ }
459
+
460
+ /** @private */
461
+ _repositionMonthScroller() {
462
+ this._monthScroller.position = this._yearScroller.position * 12 - this._originDate.getMonth();
463
+ this._visibleMonthIndex = Math.floor(this._monthScroller.position);
464
+ }
465
+
466
+ /** @private */
467
+ _onMonthScroll() {
468
+ this._repositionYearScroller();
469
+ this._doIgnoreTaps();
470
+ }
471
+
472
+ /** @private */
473
+ _onYearScroll() {
474
+ this._repositionMonthScroller();
475
+ this._doIgnoreTaps();
476
+ }
477
+
478
+ /** @private */
479
+ _onYearScrollTouchStart() {
480
+ this._notTapping = false;
481
+ setTimeout(() => {
482
+ this._notTapping = true;
483
+ }, 300);
484
+
485
+ this._repositionMonthScroller();
486
+ }
487
+
488
+ /** @private */
489
+ _onMonthScrollTouchStart() {
490
+ this._repositionYearScroller();
491
+ }
492
+
493
+ /** @private */
494
+ _doIgnoreTaps() {
495
+ this._ignoreTaps = true;
496
+ this._debouncer = Debouncer.debounce(this._debouncer, timeOut.after(300), () => {
497
+ this._ignoreTaps = false;
498
+ });
499
+ }
500
+
501
+ /** @protected */
502
+ _formatDisplayed(date, i18n, label) {
503
+ if (date && i18n && typeof i18n.formatDate === 'function') {
504
+ return i18n.formatDate(extractDateParts(date));
505
+ }
506
+ return label;
507
+ }
508
+
509
+ /** @private */
510
+ _onTodayTap() {
511
+ const today = new Date();
512
+
513
+ if (Math.abs(this._monthScroller.position - this._differenceInMonths(today, this._originDate)) < 0.001) {
514
+ // Select today only if the month scroller is positioned approximately
515
+ // at the beginning of the current month
516
+ this._selectDate(today);
517
+ this._close();
518
+ } else {
519
+ this._scrollToCurrentMonth();
520
+ }
521
+ }
522
+
523
+ /** @private */
524
+ _scrollToCurrentMonth() {
525
+ if (this.focusedDate) {
526
+ this.focusedDate = new Date();
527
+ }
528
+
529
+ this.scrollToDate(new Date(), true);
530
+ }
531
+
532
+ /** @private */
533
+ _onYearTap(e) {
534
+ if (!this._ignoreTaps && !this._notTapping) {
535
+ const scrollDelta =
536
+ e.detail.y - (this._yearScroller.getBoundingClientRect().top + this._yearScroller.clientHeight / 2);
537
+ const yearDelta = scrollDelta / this._yearScroller.itemHeight;
538
+ this._scrollToPosition(this._monthScroller.position + yearDelta * 12, true);
539
+ }
540
+ }
541
+
542
+ /** @private */
543
+ _scrollToPosition(targetPosition, animate) {
544
+ if (this._targetPosition !== undefined) {
545
+ this._targetPosition = targetPosition;
546
+ return;
547
+ }
548
+
549
+ if (!animate) {
550
+ this._monthScroller.position = targetPosition;
551
+ this._targetPosition = undefined;
552
+ this._repositionYearScroller();
553
+ this.__tryFocusDate();
554
+ return;
555
+ }
556
+
557
+ this._targetPosition = targetPosition;
558
+
559
+ let revealResolve;
560
+ this._revealPromise = new Promise((resolve) => {
561
+ revealResolve = resolve;
562
+ });
563
+
564
+ // http://gizma.com/easing/
565
+ const easingFunction = (t, b, c, d) => {
566
+ t /= d / 2;
567
+ if (t < 1) {
568
+ return (c / 2) * t * t + b;
569
+ }
570
+ t -= 1;
571
+ return (-c / 2) * (t * (t - 2) - 1) + b;
572
+ };
573
+
574
+ let start = 0;
575
+ const initialPosition = this._monthScroller.position;
576
+
577
+ const smoothScroll = (timestamp) => {
578
+ if (!start) {
579
+ start = timestamp;
580
+ }
581
+
582
+ const currentTime = timestamp - start;
583
+
584
+ if (currentTime < this.scrollDuration) {
585
+ const currentPos = easingFunction(
586
+ currentTime,
587
+ initialPosition,
588
+ this._targetPosition - initialPosition,
589
+ this.scrollDuration,
590
+ );
591
+ this._monthScroller.position = currentPos;
592
+ window.requestAnimationFrame(smoothScroll);
593
+ } else {
594
+ this.dispatchEvent(
595
+ new CustomEvent('scroll-animation-finished', {
596
+ bubbles: true,
597
+ composed: true,
598
+ detail: {
599
+ position: this._targetPosition,
600
+ oldPosition: initialPosition,
601
+ },
602
+ }),
603
+ );
604
+
605
+ this._monthScroller.position = this._targetPosition;
606
+ this._targetPosition = undefined;
607
+
608
+ revealResolve();
609
+ this._revealPromise = undefined;
610
+ }
611
+
612
+ setTimeout(this._repositionYearScroller.bind(this), 1);
613
+ };
614
+
615
+ // Start the animation.
616
+ window.requestAnimationFrame(smoothScroll);
617
+ }
618
+
619
+ /** @private */
620
+ _limit(value, range) {
621
+ return Math.min(range.max, Math.max(range.min, value));
622
+ }
623
+
624
+ /** @private */
625
+ _handleTrack(e) {
626
+ // Check if horizontal movement threshold (dx) not exceeded or
627
+ // scrolling fast vertically (ddy).
628
+ if (Math.abs(e.detail.dx) < 10 || Math.abs(e.detail.ddy) > 10) {
629
+ return;
630
+ }
631
+
632
+ // If we're flinging quickly -> start animating already.
633
+ if (Math.abs(e.detail.ddx) > this._yearScrollerWidth / 3) {
634
+ this._toggleAnimateClass(true);
635
+ }
636
+
637
+ const newTranslateX = this._translateX + e.detail.ddx;
638
+ this._translateX = this._limit(newTranslateX, {
639
+ min: 0,
640
+ max: this._yearScrollerWidth,
641
+ });
642
+ }
643
+
644
+ /** @private */
645
+ _track(e) {
646
+ if (this._desktopMode) {
647
+ // No need to track for swipe gestures on desktop.
648
+ return;
649
+ }
650
+
651
+ switch (e.detail.state) {
652
+ case 'start':
653
+ this._toggleAnimateClass(false);
654
+ break;
655
+ case 'track':
656
+ this._handleTrack(e);
657
+ break;
658
+ case 'end':
659
+ this._toggleAnimateClass(true);
660
+ if (this._translateX >= this._yearScrollerWidth / 2) {
661
+ this._closeYearScroller();
662
+ } else {
663
+ this._openYearScroller();
664
+ }
665
+ break;
666
+ default:
667
+ break;
668
+ }
669
+ }
670
+
671
+ /** @private */
672
+ _toggleAnimateClass(enable) {
673
+ if (enable) {
674
+ this.classList.add('animate');
675
+ } else {
676
+ this.classList.remove('animate');
677
+ }
678
+ }
679
+
680
+ /** @private */
681
+ _toggleYearScroller() {
682
+ if (this._isYearScrollerVisible()) {
683
+ this._closeYearScroller();
684
+ } else {
685
+ this._openYearScroller();
686
+ }
687
+ }
688
+
689
+ /** @private */
690
+ _openYearScroller() {
691
+ this._translateX = 0;
692
+ this.setAttribute('years-visible', '');
693
+ }
694
+
695
+ /** @private */
696
+ _closeYearScroller() {
697
+ this.removeAttribute('years-visible');
698
+ this._translateX = this._yearScrollerWidth;
699
+ }
700
+
701
+ /** @private */
702
+ _isYearScrollerVisible() {
703
+ return this._translateX < this._yearScrollerWidth / 2;
704
+ }
705
+
706
+ /** @private */
707
+ _translateXChanged(x) {
708
+ if (!this._desktopMode) {
709
+ this._monthScroller.style.transform = `translateX(${x - this._yearScrollerWidth}px)`;
710
+ this._yearScroller.style.transform = `translateX(${x}px)`;
711
+ }
712
+ }
713
+
714
+ /** @private */
715
+ _yearAfterXMonths(months) {
716
+ return dateAfterXMonths(months).getFullYear();
717
+ }
718
+
719
+ /** @private */
720
+ _differenceInMonths(date1, date2) {
721
+ const months = (date1.getFullYear() - date2.getFullYear()) * 12;
722
+ return months - date2.getMonth() + date1.getMonth();
723
+ }
724
+
725
+ /** @private */
726
+ _clear() {
727
+ this._selectDate('');
728
+ }
729
+
730
+ /** @private */
731
+ _close() {
732
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));
733
+ }
734
+
735
+ /** @private */
736
+ _cancel() {
737
+ this.focusedDate = this.selectedDate;
738
+ this._close();
739
+ }
740
+
741
+ /** @protected */
742
+ _preventDefault(e) {
743
+ e.preventDefault();
744
+ }
745
+
746
+ /** @private */
747
+ __toggleDate(date) {
748
+ if (dateEquals(date, this.selectedDate)) {
749
+ this._clear();
750
+ this.focusedDate = date;
751
+ } else {
752
+ this._selectDate(date);
753
+ }
754
+ }
755
+
756
+ /** @private */
757
+ __onMonthCalendarKeyDown(event) {
758
+ let handled = false;
759
+
760
+ switch (event.key) {
761
+ case 'ArrowDown':
762
+ this._moveFocusByDays(7);
763
+ handled = true;
764
+ break;
765
+ case 'ArrowUp':
766
+ this._moveFocusByDays(-7);
767
+ handled = true;
768
+ break;
769
+ case 'ArrowRight':
770
+ this._moveFocusByDays(this.__isRTL ? -1 : 1);
771
+ handled = true;
772
+ break;
773
+ case 'ArrowLeft':
774
+ this._moveFocusByDays(this.__isRTL ? 1 : -1);
775
+ handled = true;
776
+ break;
777
+ case 'Enter':
778
+ this._selectDate(this.focusedDate);
779
+ this._close();
780
+ handled = true;
781
+ break;
782
+ case ' ':
783
+ this.__toggleDate(this.focusedDate);
784
+ handled = true;
785
+ break;
786
+ case 'Home':
787
+ this._moveFocusInsideMonth(this.focusedDate, 'minDate');
788
+ handled = true;
789
+ break;
790
+ case 'End':
791
+ this._moveFocusInsideMonth(this.focusedDate, 'maxDate');
792
+ handled = true;
793
+ break;
794
+ case 'PageDown':
795
+ this._moveFocusByMonths(event.shiftKey ? 12 : 1);
796
+ handled = true;
797
+ break;
798
+ case 'PageUp':
799
+ this._moveFocusByMonths(event.shiftKey ? -12 : -1);
800
+ handled = true;
801
+ break;
802
+ case 'Tab':
803
+ this._onTabKeyDown(event, 'calendar');
804
+ break;
805
+ default:
806
+ break;
807
+ }
808
+
809
+ if (handled) {
810
+ event.preventDefault();
811
+ event.stopPropagation();
812
+ }
813
+ }
814
+
815
+ /** @private */
816
+ _onTabKeyDown(event, section) {
817
+ // Stop propagation to avoid focus-trap
818
+ // listener when used in a modal dialog.
819
+ event.stopPropagation();
820
+
821
+ switch (section) {
822
+ case 'calendar':
823
+ if (event.shiftKey) {
824
+ event.preventDefault();
825
+
826
+ if (this.hasAttribute('fullscreen')) {
827
+ // Trap focus in the overlay
828
+ this.focusCancel();
829
+ } else {
830
+ this.__focusInput();
831
+ }
832
+ }
833
+ break;
834
+ case 'today':
835
+ if (event.shiftKey) {
836
+ event.preventDefault();
837
+ this.focusDateElement();
838
+ }
839
+ break;
840
+ case 'cancel':
841
+ if (!event.shiftKey) {
842
+ event.preventDefault();
843
+
844
+ if (this.hasAttribute('fullscreen')) {
845
+ // Trap focus in the overlay
846
+ this.focusDateElement();
847
+ } else {
848
+ this.__focusInput();
849
+ }
850
+ }
851
+ break;
852
+ default:
853
+ break;
854
+ }
855
+ }
856
+
857
+ /** @private */
858
+ __onTodayButtonKeyDown(event) {
859
+ if (event.key === 'Tab') {
860
+ this._onTabKeyDown(event, 'today');
861
+ }
862
+ }
863
+
864
+ /** @private */
865
+ __onCancelButtonKeyDown(event) {
866
+ if (event.key === 'Tab') {
867
+ this._onTabKeyDown(event, 'cancel');
868
+ }
869
+ }
870
+
871
+ /** @private */
872
+ __focusInput() {
873
+ this.dispatchEvent(new CustomEvent('focus-input', { bubbles: true, composed: true }));
874
+ }
875
+
876
+ /** @private */
877
+ __tryFocusDate() {
878
+ const dateToFocus = this.__pendingDateFocus;
879
+ if (dateToFocus) {
880
+ // Check the date element with tabindex="0"
881
+ const dateElement = this.focusableDateElement;
882
+
883
+ if (dateElement && dateEquals(dateElement.date, this.__pendingDateFocus)) {
884
+ delete this.__pendingDateFocus;
885
+ dateElement.focus();
886
+ }
887
+ }
888
+ }
889
+
890
+ async focusDate(date, keepMonth) {
891
+ const dateToFocus = date || this.selectedDate || this.initialPosition || new Date();
892
+ this.focusedDate = dateToFocus;
893
+ if (!keepMonth) {
894
+ this._focusedMonthDate = dateToFocus.getDate();
895
+ }
896
+ await this.focusDateElement(false);
897
+ }
898
+
899
+ async focusDateElement(reveal = true) {
900
+ this.__pendingDateFocus = this.focusedDate;
901
+
902
+ // Wait for `vaadin-month-calendar` elements to be rendered
903
+ if (!this.calendars.length) {
904
+ await new Promise((resolve) => {
905
+ afterNextRender(this, () => {
906
+ // Force dom-repeat elements to render
907
+ flush();
908
+ resolve();
909
+ });
910
+ });
911
+ }
912
+
913
+ // Reveal focused date unless it has been just set,
914
+ // which triggers `revealDate()` in the observer.
915
+ if (reveal) {
916
+ this.revealDate(this.focusedDate);
917
+ }
918
+
919
+ if (this._revealPromise) {
920
+ // Wait for focused date to be scrolled into view.
921
+ await this._revealPromise;
922
+ }
923
+
924
+ this.__tryFocusDate();
925
+ }
926
+
927
+ /** @private */
928
+ _focusClosestDate(focus) {
929
+ this.focusDate(getClosestDate(focus, [this.minDate, this.maxDate]));
930
+ }
931
+
932
+ /** @private */
933
+ _focusAllowedDate(dateToFocus, diff, keepMonth) {
934
+ if (this._dateAllowed(dateToFocus)) {
935
+ this.focusDate(dateToFocus, keepMonth);
936
+ } else if (this._dateAllowed(this.focusedDate)) {
937
+ // Move to min or max date
938
+ if (diff > 0) {
939
+ // Down, Right or Page Down key
940
+ this.focusDate(this.maxDate);
941
+ } else {
942
+ // Up, Left or Page Up key
943
+ this.focusDate(this.minDate);
944
+ }
945
+ } else {
946
+ // Move to closest allowed date
947
+ this._focusClosestDate(this.focusedDate);
948
+ }
949
+ }
950
+
951
+ /** @private */
952
+ _getDateDiff(months, days) {
953
+ const date = new Date(0, 0);
954
+ date.setFullYear(this.focusedDate.getFullYear());
955
+ date.setMonth(this.focusedDate.getMonth() + months);
956
+ if (days) {
957
+ date.setDate(this.focusedDate.getDate() + days);
958
+ }
959
+ return date;
960
+ }
961
+
962
+ /** @private */
963
+ _moveFocusByDays(days) {
964
+ const dateToFocus = this._getDateDiff(0, days);
965
+
966
+ this._focusAllowedDate(dateToFocus, days, false);
967
+ }
968
+
969
+ /** @private */
970
+ _moveFocusByMonths(months) {
971
+ const dateToFocus = this._getDateDiff(months);
972
+ const targetMonth = dateToFocus.getMonth();
973
+
974
+ if (!this._focusedMonthDate) {
975
+ this._focusedMonthDate = this.focusedDate.getDate();
976
+ }
977
+
978
+ dateToFocus.setDate(this._focusedMonthDate);
979
+
980
+ if (dateToFocus.getMonth() !== targetMonth) {
981
+ dateToFocus.setDate(0);
982
+ }
983
+
984
+ this._focusAllowedDate(dateToFocus, months, true);
985
+ }
986
+
987
+ /** @private */
988
+ _moveFocusInsideMonth(focusedDate, property) {
989
+ const dateToFocus = new Date(0, 0);
990
+ dateToFocus.setFullYear(focusedDate.getFullYear());
991
+
992
+ if (property === 'minDate') {
993
+ dateToFocus.setMonth(focusedDate.getMonth());
994
+ dateToFocus.setDate(1);
995
+ } else {
996
+ dateToFocus.setMonth(focusedDate.getMonth() + 1);
997
+ dateToFocus.setDate(0);
998
+ }
999
+
1000
+ if (this._dateAllowed(dateToFocus)) {
1001
+ this.focusDate(dateToFocus);
1002
+ } else if (this._dateAllowed(focusedDate)) {
1003
+ // Move to minDate or maxDate
1004
+ this.focusDate(this[property]);
1005
+ } else {
1006
+ // Move to closest allowed date
1007
+ this._focusClosestDate(focusedDate);
1008
+ }
1009
+ }
1010
+
1011
+ /** @private */
1012
+ _dateAllowed(date, min = this.minDate, max = this.maxDate) {
1013
+ return (!min || date >= min) && (!max || date <= max);
1014
+ }
1015
+
1016
+ /** @private */
1017
+ _isTodayAllowed(min, max) {
1018
+ const today = new Date();
1019
+ const todayMidnight = new Date(0, 0);
1020
+ todayMidnight.setFullYear(today.getFullYear());
1021
+ todayMidnight.setMonth(today.getMonth());
1022
+ todayMidnight.setDate(today.getDate());
1023
+ return this._dateAllowed(todayMidnight, min, max);
1024
+ }
1025
+
1026
+ /**
1027
+ * Fired when the scroller reaches the target scrolling position.
1028
+ * @event scroll-animation-finished
1029
+ * @param {Number} detail.position new position
1030
+ * @param {Number} detail.oldPosition old position
1031
+ */
1032
+ };