@vaadin/date-picker 25.3.0-dev.3a3c2d7d2a → 25.3.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.
@@ -5,23 +5,25 @@
5
5
  */
6
6
  import { hideOthers } from '@vaadin/a11y-base/src/aria-hidden.js';
7
7
  import { DelegateFocusMixin } from '@vaadin/a11y-base/src/delegate-focus-mixin.js';
8
- import { isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
8
+ import { isElementFocused, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
9
9
  import { KeyboardMixin } from '@vaadin/a11y-base/src/keyboard-mixin.js';
10
10
  import { isIOS } from '@vaadin/component-base/src/browser-utils.js';
11
+ import { setOrRemoveAttribute } from '@vaadin/component-base/src/dom-utils.js';
11
12
  import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js';
12
13
  import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js';
13
14
  import { InputConstraintsMixin } from '@vaadin/field-base/src/input-constraints-mixin.js';
14
15
  import { VirtualKeyboardController } from '@vaadin/field-base/src/virtual-keyboard-controller.js';
16
+ import { DateMetadataController } from './vaadin-date-metadata-controller.js';
15
17
  import {
16
18
  dateAllowed,
17
19
  dateEquals,
20
+ dateSelectable,
18
21
  extractDateParts,
19
22
  formatISODate,
20
23
  getAdjustedYear,
21
24
  getClosestDate,
22
25
  parseDate,
23
26
  } from './vaadin-date-picker-helper.js';
24
- import { DisabledDatesController } from './vaadin-disabled-dates-controller.js';
25
27
 
26
28
  export const datePickerI18nDefaults = Object.freeze({
27
29
  monthNames: [
@@ -43,6 +45,7 @@ export const datePickerI18nDefaults = Object.freeze({
43
45
  firstDayOfWeek: 0,
44
46
  today: 'Today',
45
47
  cancel: 'Cancel',
48
+ dialogAccessibleName: 'Calendar',
46
49
  referenceDate: '',
47
50
  formatDate(d) {
48
51
  const yearStr = String(d.year).replace(/\d+/u, (y) => '0000'.substr(y.length) + y);
@@ -60,7 +63,7 @@ export const datePickerI18nDefaults = Object.freeze({
60
63
  date = parseInt(parts[1]);
61
64
  year = parseInt(parts[2]);
62
65
  if (parts[2].length < 3 && year >= 0) {
63
- const usedReferenceDate = this.referenceDate ? parseDate(this.referenceDate) : new Date();
66
+ const usedReferenceDate = parseDate(this.referenceDate) || new Date();
64
67
  year = getAdjustedYear(usedReferenceDate, year, month, date);
65
68
  }
66
69
  } else if (parts.length === 2) {
@@ -203,6 +206,10 @@ export const DatePickerMixin = (subclass) =>
203
206
  * Receives a `DatePickerDate` object of the date to be selected and should return a
204
207
  * boolean.
205
208
  *
209
+ * The function is called once per date and has to answer synchronously. Use
210
+ * `dateMetadataProvider` when the answer has to be loaded first, or when dates also need
211
+ * custom part names. A date is disabled when either of the two disables it.
212
+ *
206
213
  * @type {function(DatePickerDate): boolean | undefined}
207
214
  */
208
215
  isDateDisabled: {
@@ -210,27 +217,45 @@ export const DatePickerMixin = (subclass) =>
210
217
  },
211
218
 
212
219
  /**
213
- * A batch function that is consulted for a range of dates that the calendar is about
214
- * to render. It receives a `DatePickerDateRange` object (`{ start, end }` of
215
- * `DatePickerDate`) and returns, or resolves with, an array of `DatePickerDate` objects
216
- * that should be disabled within that range.
220
+ * A function that provides metadata for the dates the calendar is about to render: whether they
221
+ * are disabled, and CSS `part` names for styling from outside using the `::part()` selector.
222
+ * Unlike `isDateDisabled`, which is called once per date, the metadata provider is called for
223
+ * a range of dates at a time, and again as the calendar renders further dates.
224
+ *
225
+ * It receives a `DatePickerDateRange` and returns an array of `DatePickerDateMetadata` objects
226
+ * for the dates in that range that have metadata. It can return a `Promise` to load the metadata
227
+ * asynchronously, and `null` or `undefined` when no date in the range has metadata.
228
+ *
229
+ * The returned array has the following structure:
230
+ *
231
+ * ```js
232
+ * [
233
+ * // The date is an ISO 8601 string.
234
+ * { date: '2026-01-01', disabled: true },
217
235
  *
218
- * Unlike `isDateDisabled`, which is called once per date, this function is called for a
219
- * range of dates at a time, and again as the calendar renders further dates. The size of
220
- * the range is decided by the calendar and may span multiple months. It may return a
221
- * `Promise`, in which case the affected dates render in a non-selectable pending state
222
- * until it resolves.
236
+ * // Adds a custom part name to the date.
237
+ * { date: '2026-01-02', part: 'busy' },
238
+ * ]
239
+ * ```
223
240
  *
224
- * Dates disabled by this function are combined with `min`, `max` and `isDateDisabled`:
225
- * a date is disabled if it is out of the min/max range, or `isDateDisabled` returns
226
- * `true`, or it is included in this function's result.
241
+ * A date is disabled if its metadata marks it disabled, or `isDateDisabled` returns `true`, or
242
+ * it is outside `min` and `max`. Disabled dates are not selectable, and typing a disabled date in
243
+ * the field makes it invalid. The provider does not affect which date is focused when opening the
244
+ * overlay. Use `initialPosition` property to provide a selectable date.
227
245
  *
228
- * Keep a stable reference to the function. Assigning a new function resets the internal
229
- * cache and re-fetches every visible range.
246
+ * While a returned `Promise` is pending, the dates it covers are not disabled yet and render with
247
+ * the `loading` part. If the function throws or rejects, corresponding dates are requested again
248
+ * the next time the user navigates.
230
249
  *
231
- * @type {function(DatePickerDateRange): Array<DatePickerDate> | Promise<Array<DatePickerDate>> | undefined}
250
+ * The provider is used for validation also when the overlay is closed. Date is considered valid
251
+ * while the provider is pending, and is re-validated again after the metadata is loaded.
252
+ *
253
+ * Keep a stable reference to the function: assigning a new one clears the cache and re-fetches
254
+ * visible range. Call `clearCache()` to re-fetch when the data behind the same function changed.
255
+ *
256
+ * @type {DatePickerDateMetadataProvider | null | undefined}
232
257
  */
233
- disabledDatesProvider: {
258
+ dateMetadataProvider: {
234
259
  type: Function,
235
260
  },
236
261
 
@@ -287,8 +312,7 @@ export const DatePickerMixin = (subclass) =>
287
312
  return [
288
313
  '_selectedDateChanged(_selectedDate, __effectiveI18n)',
289
314
  '_focusedDateChanged(_focusedDate, __effectiveI18n)',
290
- '__updateOverlayContent(_overlayContent, __effectiveI18n, label, _minDate, _maxDate, _focusedDate, _selectedDate, showWeekNumbers, isDateDisabled, disabledDatesProvider, __enteredDate)',
291
- '__disabledDatesProviderChanged(disabledDatesProvider)',
315
+ '__updateOverlayContent(_overlayContent, __effectiveI18n, label, _minDate, _maxDate, _focusedDate, _selectedDate, showWeekNumbers, isDateDisabled, __enteredDate)',
292
316
  '__updateOverlayContentTheme(_overlayContent, _theme)',
293
317
  '__updateOverlayContentFullScreen(_overlayContent, _fullscreen)',
294
318
  ];
@@ -299,7 +323,7 @@ export const DatePickerMixin = (subclass) =>
299
323
  }
300
324
 
301
325
  static get constraints() {
302
- return [...super.constraints, 'min', 'max'];
326
+ return [...super.constraints, 'min', 'max', 'dateMetadataProvider'];
303
327
  }
304
328
 
305
329
  constructor() {
@@ -307,6 +331,9 @@ export const DatePickerMixin = (subclass) =>
307
331
 
308
332
  this._boundOnClick = this._onClick.bind(this);
309
333
  this._boundOnScroll = this._onScroll.bind(this);
334
+
335
+ this._dateMetadataController = new DateMetadataController(this, () => this.__onDateMetadataChanged());
336
+ this.addController(this._dateMetadataController);
310
337
  }
311
338
 
312
339
  /**
@@ -349,6 +376,10 @@ export const DatePickerMixin = (subclass) =>
349
376
  * // Translation of the Cancel button text.
350
377
  * cancel: 'Cancel',
351
378
  *
379
+ * // Accessible name of the overlay content, announced by screen readers
380
+ * // when the overlay opens.
381
+ * dialogAccessibleName: 'Calendar',
382
+ *
352
383
  * // Used for adjusting the year value when parsing dates with short years.
353
384
  * // The year values between 0 and 99 are evaluated and adjusted.
354
385
  * // Example: for a referenceDate of 1970-10-30;
@@ -428,7 +459,10 @@ export const DatePickerMixin = (subclass) =>
428
459
  super._onFocus(event);
429
460
 
430
461
  if (this._noInput && !isKeyboardActive()) {
462
+ // Blur to hide the virtual keyboard, but do not validate.
463
+ this.__ignoreInternalBlur = true;
431
464
  event.target.blur();
465
+ this.__ignoreInternalBlur = false;
432
466
  }
433
467
  }
434
468
 
@@ -439,6 +473,10 @@ export const DatePickerMixin = (subclass) =>
439
473
  _onBlur(event) {
440
474
  super._onBlur(event);
441
475
 
476
+ if (this.__ignoreInternalBlur) {
477
+ return;
478
+ }
479
+
442
480
  if (!this.opened) {
443
481
  this.__commitParsedOrFocusedDate();
444
482
 
@@ -464,13 +502,6 @@ export const DatePickerMixin = (subclass) =>
464
502
 
465
503
  this.addController(new VirtualKeyboardController(this));
466
504
 
467
- // Owns the cache of dates resolved by `disabledDatesProvider`. It lives on the date-picker
468
- // rather than the overlay content so validation works even when the overlay is never opened.
469
- // The open overlay reads the same controller to render months and show the loading spinner.
470
- this._disabledDatesController = new DisabledDatesController(this, () => this.__onDisabledDatesChanged());
471
- this.addController(this._disabledDatesController);
472
- this._disabledDatesController.setProvider(this.disabledDatesProvider);
473
-
474
505
  this._overlayElement = this.$.overlay;
475
506
  }
476
507
 
@@ -478,6 +509,11 @@ export const DatePickerMixin = (subclass) =>
478
509
  updated(props) {
479
510
  super.updated(props);
480
511
 
512
+ if (props.has('dateMetadataProvider')) {
513
+ this._dateMetadataController.setProvider(this.dateMetadataProvider);
514
+ this.__reloadDateMetadata();
515
+ }
516
+
481
517
  if (props.has('showWeekNumbers') || props.has('__effectiveI18n')) {
482
518
  // Currently only supported for locales that start the week on Monday.
483
519
  this.toggleAttribute('week-numbers', this.showWeekNumbers && this.__effectiveI18n.firstDayOfWeek === 1);
@@ -520,6 +556,28 @@ export const DatePickerMixin = (subclass) =>
520
556
  this.$.overlay.close();
521
557
  }
522
558
 
559
+ /**
560
+ * Clears the `dateMetadataProvider` cache and reloads the date metadata.
561
+ */
562
+ clearCache() {
563
+ this._dateMetadataController.clearCache();
564
+ this.__reloadDateMetadata();
565
+ }
566
+
567
+ /**
568
+ * Asks for what the dropped cache was holding: the months the overlay is showing, and the month
569
+ * of the value being validated. Requested from here rather than from the controller's
570
+ * notification, which would turn a provider that keeps failing into an endless retry, since a
571
+ * failed month is dropped and so becomes missing again.
572
+ * @private
573
+ */
574
+ __reloadDateMetadata() {
575
+ if (this.opened) {
576
+ this._overlayContent?.loadVisibleDateMetadata();
577
+ }
578
+ this.__ensureSelectedDateLoaded();
579
+ }
580
+
523
581
  /** @private */
524
582
  __ensureContent() {
525
583
  if (this._overlayContent) {
@@ -613,8 +671,13 @@ export const DatePickerMixin = (subclass) =>
613
671
  const inputValid = !inputValue || (!!this._selectedDate && inputValue === this.__formatDate(this._selectedDate));
614
672
  const isDateValid =
615
673
  !this._selectedDate ||
616
- (dateAllowed(this._selectedDate, this._minDate, this._maxDate, this.isDateDisabled) &&
617
- !this.__isDateDisabledByProvider(this._selectedDate));
674
+ dateSelectable(
675
+ this._selectedDate,
676
+ this._minDate,
677
+ this._maxDate,
678
+ this.isDateDisabled,
679
+ this._dateMetadataController,
680
+ );
618
681
 
619
682
  let inputValidity = true;
620
683
  if (this.inputElement && this.inputElement.checkValidity) {
@@ -625,90 +688,44 @@ export const DatePickerMixin = (subclass) =>
625
688
  }
626
689
 
627
690
  /**
628
- * Returns true if the given date is known to be disabled by `disabledDatesProvider`. The
629
- * result comes from the controller's cache of already-loaded ranges. The month containing a
630
- * selected date is loaded on demand (see `_selectedDateChanged`), so a value typed while the
631
- * overlay is closed is re-validated once the provider answers (see `__onDisabledDatesChanged`).
632
- * Until then the date is treated as allowed, matching the overlay's rendering.
691
+ * Asks the controller for the month holding the selected date, so a value that was set or typed
692
+ * without ever opening the overlay is still checked against the provider. Validation is re-run
693
+ * from the host callback once the month resolves.
633
694
  * @private
634
695
  */
635
- __isDateDisabledByProvider(date) {
636
- const controller = this._disabledDatesController;
637
- return !!controller && !!controller.provider && controller.isDateDisabled(date);
638
- }
639
-
640
- /** @private */
641
- __disabledDatesProviderChanged(disabledDatesProvider) {
642
- // The controller is created in `ready()`; `setProvider` is called there for the initial value.
643
- if (this._disabledDatesController) {
644
- this._disabledDatesController.setProvider(disabledDatesProvider);
645
- this.__ensureSelectedDateLoaded();
696
+ __ensureSelectedDateLoaded() {
697
+ const controller = this._dateMetadataController;
698
+ const awaiting = !!(controller?.provider && this._selectedDate && !controller.isMonthLoaded(this._selectedDate));
699
+ // Always assigned, so clearing the value or removing the provider while a request is in
700
+ // flight disarms the pending re-validation, and a later answer for some other month does not
701
+ // re-validate a value that never waited for it.
702
+ this.__awaitingProviderValidation = awaiting;
703
+ if (awaiting) {
704
+ controller.ensureRangeLoaded(this._selectedDate, this._selectedDate);
646
705
  }
647
706
  }
648
707
 
649
708
  /**
650
- * Asks the controller to resolve the month containing the selected date, so that a value set
651
- * or typed while the overlay is closed can be validated against the provider without opening
652
- * the overlay. When the month resolves, `__onDisabledDatesChanged` re-runs validation.
709
+ * Called by the date metadata controller, one microtask after its state changed
710
+ * and coalesced, so this never writes reactive state from inside an update. The
711
+ * rendered months refresh on their own because they subscribe to the controller.
653
712
  * @private
654
713
  */
655
- __ensureSelectedDateLoaded() {
656
- const controller = this._disabledDatesController;
657
- if (controller?.provider && this._selectedDate && !controller.isMonthLoaded(this._selectedDate)) {
658
- this.__awaitingProviderValidation = true;
659
- controller.ensureRangeLoaded(this._selectedDate, this._selectedDate);
660
- }
661
- }
714
+ __onDateMetadataChanged() {
715
+ const controller = this._dateMetadataController;
662
716
 
663
- /** @private */
664
- __onDisabledDatesChanged() {
665
- const controller = this._disabledDatesController;
666
- // Push the new loading and cache state to the open overlay so it re-renders the months and
667
- // updates the spinner. When the overlay is closed there is nothing to update.
717
+ // Only the open overlay has a spinner to update and a today button to re-evaluate.
668
718
  if (this._overlayContent) {
669
- this._overlayContent.loading = controller.loading;
670
- this._overlayContent._disabledDatesVersion += 1;
719
+ this._overlayContent.loading = controller.isLoading();
720
+ this._overlayContent.updateTodayButton();
671
721
  }
672
- // Re-validate once the month containing the selected value has resolved, so a value typed
673
- // while the picker was closed (autoOpenDisabled) is rejected as soon as the provider answers.
722
+
723
+ // Runs whether or not the overlay was ever opened, which is the case this exists for: a value
724
+ // set or typed with the overlay closed is reported invalid as soon as its month answers.
674
725
  if (this.__awaitingProviderValidation && this._selectedDate && controller.isMonthLoaded(this._selectedDate)) {
675
726
  this.__awaitingProviderValidation = false;
676
727
  this._requestValidation();
677
728
  }
678
-
679
- this.__adjustInitialFocusForProvider();
680
- }
681
-
682
- /**
683
- * Moves the overlay's initial focus off a date the provider turns out to disable, once the
684
- * provider has answered for that month. Only touches the auto-picked initial date and only
685
- * while the user has not navigated away, so disabled dates the user focuses on purpose (which
686
- * stay keyboard-focusable) are left alone.
687
- * @private
688
- */
689
- __adjustInitialFocusForProvider() {
690
- const content = this._overlayContent;
691
- const controller = this._disabledDatesController;
692
- const initial = this.__initialFocusDate;
693
- if (!content || !initial || !controller.provider) {
694
- return;
695
- }
696
- // The user has moved focus; stop trying to adjust the initial date.
697
- if (!dateEquals(content.focusedDate, initial)) {
698
- this.__initialFocusDate = null;
699
- return;
700
- }
701
- // Wait until the provider has answered for the initial month.
702
- if (!controller.isMonthLoaded(initial)) {
703
- return;
704
- }
705
- this.__initialFocusDate = null;
706
- if (controller.isDateDisabled(initial)) {
707
- const closest = content.__closestSelectableDate(initial);
708
- if (closest) {
709
- content.focusDate(closest);
710
- }
711
- }
712
729
  }
713
730
 
714
731
  /**
@@ -894,14 +911,12 @@ export const DatePickerMixin = (subclass) =>
894
911
  this._applyInputValue(selectedDate);
895
912
  }
896
913
 
897
- // Preload the provider's answer for the selected month so the value can be validated even
898
- // when the overlay is never opened.
899
- this.__ensureSelectedDateLoaded();
900
-
901
914
  this.value = this._formatISO(selectedDate);
902
915
  this._ignoreFocusedDateChange = true;
903
916
  this._focusedDate = selectedDate;
904
917
  this._ignoreFocusedDateChange = false;
918
+
919
+ this.__ensureSelectedDateLoaded();
905
920
  }
906
921
 
907
922
  /** @private */
@@ -969,13 +984,11 @@ export const DatePickerMixin = (subclass) =>
969
984
  selectedDate,
970
985
  showWeekNumbers,
971
986
  isDateDisabled,
972
- disabledDatesProvider,
973
987
  enteredDate,
974
988
  ) {
975
989
  if (overlayContent) {
976
- // Share the date-picker's controller so the overlay renders from the same cache that
977
- // validation uses. Assigned before the other properties, which trigger `__updateCalendars`.
978
- overlayContent._disabledDatesController = this._disabledDatesController;
990
+ // Reuse the date-picker's controller so the overlay shares the same cache.
991
+ overlayContent._dateMetadataController = this._dateMetadataController;
979
992
  overlayContent.i18n = effectiveI18n;
980
993
  overlayContent.label = label;
981
994
  overlayContent.minDate = minDate;
@@ -984,7 +997,6 @@ export const DatePickerMixin = (subclass) =>
984
997
  overlayContent.selectedDate = selectedDate;
985
998
  overlayContent.showWeekNumbers = showWeekNumbers;
986
999
  overlayContent.isDateDisabled = isDateDisabled;
987
- overlayContent.disabledDatesProvider = disabledDatesProvider;
988
1000
  overlayContent.enteredDate = enteredDate;
989
1001
  }
990
1002
  }
@@ -992,11 +1004,7 @@ export const DatePickerMixin = (subclass) =>
992
1004
  /** @private */
993
1005
  __updateOverlayContentTheme(overlayContent, theme) {
994
1006
  if (overlayContent) {
995
- if (theme) {
996
- overlayContent.setAttribute('theme', theme);
997
- } else {
998
- overlayContent.removeAttribute('theme');
999
- }
1007
+ setOrRemoveAttribute(overlayContent, 'theme', theme);
1000
1008
  }
1001
1009
  }
1002
1010
 
@@ -1033,12 +1041,6 @@ export const DatePickerMixin = (subclass) =>
1033
1041
  content.focusedDate = scrollFocusDate;
1034
1042
  this._ignoreFocusedDateChange = false;
1035
1043
 
1036
- // When opening without a selected value, remember the auto-picked initial date so it can be
1037
- // moved off a provider-disabled date once the provider answers (see __onDisabledDatesChanged).
1038
- // A date the user selected themselves is left in place even if the provider disables it.
1039
- this.__initialFocusDate = this._selectedDate ? null : scrollFocusDate;
1040
- this.__adjustInitialFocusForProvider();
1041
-
1042
1044
  window.addEventListener('scroll', this._boundOnScroll, true);
1043
1045
 
1044
1046
  if (this._focusOverlayOnOpen) {
@@ -1102,7 +1104,7 @@ export const DatePickerMixin = (subclass) =>
1102
1104
 
1103
1105
  /** @protected */
1104
1106
  _onOverlayClosed() {
1105
- this.__initialFocusDate = null;
1107
+ this._overlayContent?.cancelLoadVisibleDateMetadata();
1106
1108
 
1107
1109
  // Reset `aria-hidden` state.
1108
1110
  if (this.__showOthers) {
@@ -1122,6 +1124,12 @@ export const DatePickerMixin = (subclass) =>
1122
1124
  if (!this.value && !this._keyboardActive) {
1123
1125
  this._requestValidation();
1124
1126
  }
1127
+
1128
+ // Focusout events while closing arrive with `opened` still true and keep the focused state.
1129
+ // Clear it here unless focus was restored to the input, as at a wide viewport or on Esc.
1130
+ if (!this.inputElement || !isElementFocused(this.inputElement)) {
1131
+ this._setFocused(false);
1132
+ }
1125
1133
  }
1126
1134
 
1127
1135
  /** @private */