@vaadin/date-picker 25.3.0-dev.1fa5a51482 → 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,9 +5,10 @@
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';
@@ -16,6 +17,7 @@ import { DateMetadataController } from './vaadin-date-metadata-controller.js';
16
17
  import {
17
18
  dateAllowed,
18
19
  dateEquals,
20
+ dateSelectable,
19
21
  extractDateParts,
20
22
  formatISODate,
21
23
  getAdjustedYear,
@@ -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,33 +217,43 @@ export const DatePickerMixin = (subclass) =>
210
217
  },
211
218
 
212
219
  /**
213
- * A batch function that fetches metadata for a range of dates the calendar is about to
214
- * render. It receives a `DatePickerDateRange` object (`{ start, end }` of `DatePickerDate`)
215
- * and returns, or resolves with, an array of metadata objects — a `DatePickerDate` extended
216
- * with metadata fields such as `disabled` and custom `part` names, e.g.
217
- * `{ year, month, day, disabled: true, part: 'busy' }` — for the dates that have metadata
218
- * 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 },
235
+ *
236
+ * // Adds a custom part name to the date.
237
+ * { date: '2026-01-02', part: 'busy' },
238
+ * ]
239
+ * ```
219
240
  *
220
- * Unlike `isDateDisabled`, which is called once per date, this function is called for a
221
- * range of dates at a time, and again as the calendar renders further dates. The size of
222
- * the range is decided by the calendar and may span multiple months. It may return a
223
- * `Promise`, in which case the affected dates render in a non-selectable pending state
224
- * until it resolves.
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.
225
245
  *
226
- * `disabled` from the metadata is combined with `min`, `max` and `isDateDisabled`: a date
227
- * is disabled if it is out of the min/max range, or `isDateDisabled` returns `true`, or its
228
- * metadata marks it disabled. `part` names are added to the date cell's `part` attribute so
229
- * a theme can style specific dates via `::part()`.
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
- * Both `disabled` and `part` are returned by this one function, rather than by separate
232
- * generators, so a single backend query (for example in Flow) can answer both at once
233
- * instead of being split into two passes over the same data.
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.
234
252
  *
235
- * Keep a stable reference to the function. Assigning a new function resets the internal
236
- * cache and re-fetches every visible range. To reload after the underlying data changes
237
- * while keeping the same function, call `clearCache()`.
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.
238
255
  *
239
- * @type {function(DatePickerDateRange): Array<DatePickerDateMetadata> | Promise<Array<DatePickerDateMetadata>> | undefined}
256
+ * @type {DatePickerDateMetadataProvider | null | undefined}
240
257
  */
241
258
  dateMetadataProvider: {
242
259
  type: Function,
@@ -295,8 +312,7 @@ export const DatePickerMixin = (subclass) =>
295
312
  return [
296
313
  '_selectedDateChanged(_selectedDate, __effectiveI18n)',
297
314
  '_focusedDateChanged(_focusedDate, __effectiveI18n)',
298
- '__updateOverlayContent(_overlayContent, __effectiveI18n, label, _minDate, _maxDate, _focusedDate, _selectedDate, showWeekNumbers, isDateDisabled, dateMetadataProvider, __enteredDate)',
299
- '__dateMetadataProviderChanged(dateMetadataProvider)',
315
+ '__updateOverlayContent(_overlayContent, __effectiveI18n, label, _minDate, _maxDate, _focusedDate, _selectedDate, showWeekNumbers, isDateDisabled, __enteredDate)',
300
316
  '__updateOverlayContentTheme(_overlayContent, _theme)',
301
317
  '__updateOverlayContentFullScreen(_overlayContent, _fullscreen)',
302
318
  ];
@@ -307,7 +323,7 @@ export const DatePickerMixin = (subclass) =>
307
323
  }
308
324
 
309
325
  static get constraints() {
310
- return [...super.constraints, 'min', 'max'];
326
+ return [...super.constraints, 'min', 'max', 'dateMetadataProvider'];
311
327
  }
312
328
 
313
329
  constructor() {
@@ -315,6 +331,9 @@ export const DatePickerMixin = (subclass) =>
315
331
 
316
332
  this._boundOnClick = this._onClick.bind(this);
317
333
  this._boundOnScroll = this._onScroll.bind(this);
334
+
335
+ this._dateMetadataController = new DateMetadataController(this, () => this.__onDateMetadataChanged());
336
+ this.addController(this._dateMetadataController);
318
337
  }
319
338
 
320
339
  /**
@@ -357,6 +376,10 @@ export const DatePickerMixin = (subclass) =>
357
376
  * // Translation of the Cancel button text.
358
377
  * cancel: 'Cancel',
359
378
  *
379
+ * // Accessible name of the overlay content, announced by screen readers
380
+ * // when the overlay opens.
381
+ * dialogAccessibleName: 'Calendar',
382
+ *
360
383
  * // Used for adjusting the year value when parsing dates with short years.
361
384
  * // The year values between 0 and 99 are evaluated and adjusted.
362
385
  * // Example: for a referenceDate of 1970-10-30;
@@ -436,7 +459,10 @@ export const DatePickerMixin = (subclass) =>
436
459
  super._onFocus(event);
437
460
 
438
461
  if (this._noInput && !isKeyboardActive()) {
462
+ // Blur to hide the virtual keyboard, but do not validate.
463
+ this.__ignoreInternalBlur = true;
439
464
  event.target.blur();
465
+ this.__ignoreInternalBlur = false;
440
466
  }
441
467
  }
442
468
 
@@ -447,6 +473,10 @@ export const DatePickerMixin = (subclass) =>
447
473
  _onBlur(event) {
448
474
  super._onBlur(event);
449
475
 
476
+ if (this.__ignoreInternalBlur) {
477
+ return;
478
+ }
479
+
450
480
  if (!this.opened) {
451
481
  this.__commitParsedOrFocusedDate();
452
482
 
@@ -472,13 +502,6 @@ export const DatePickerMixin = (subclass) =>
472
502
 
473
503
  this.addController(new VirtualKeyboardController(this));
474
504
 
475
- // Owns the cache of dates resolved by `dateMetadataProvider`. It lives on the date-picker
476
- // rather than the overlay content so validation works even when the overlay is never opened.
477
- // The open overlay reads the same controller to render months and show the loading spinner.
478
- this._dateMetadataController = new DateMetadataController(this, () => this.__onDateMetadataChanged());
479
- this.addController(this._dateMetadataController);
480
- this._dateMetadataController.setProvider(this.dateMetadataProvider);
481
-
482
505
  this._overlayElement = this.$.overlay;
483
506
  }
484
507
 
@@ -486,6 +509,11 @@ export const DatePickerMixin = (subclass) =>
486
509
  updated(props) {
487
510
  super.updated(props);
488
511
 
512
+ if (props.has('dateMetadataProvider')) {
513
+ this._dateMetadataController.setProvider(this.dateMetadataProvider);
514
+ this.__reloadDateMetadata();
515
+ }
516
+
489
517
  if (props.has('showWeekNumbers') || props.has('__effectiveI18n')) {
490
518
  // Currently only supported for locales that start the week on Monday.
491
519
  this.toggleAttribute('week-numbers', this.showWeekNumbers && this.__effectiveI18n.firstDayOfWeek === 1);
@@ -529,22 +557,24 @@ export const DatePickerMixin = (subclass) =>
529
557
  }
530
558
 
531
559
  /**
532
- * Clears the cached date metadata and reloads it from `dateMetadataProvider`. Call this when
533
- * the data behind the provider has changed (for example a date became booked) so the calendar
534
- * reflects it, without having to replace the provider function.
535
- *
536
- * The visible range is reloaded when the overlay is open, and the selected value is
537
- * re-validated once its month resolves.
560
+ * Clears the `dateMetadataProvider` cache and reloads the date metadata.
538
561
  */
539
562
  clearCache() {
540
- const controller = this._dateMetadataController;
541
- if (!controller) {
542
- return;
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();
543
577
  }
544
- // Drops the cache and notifies, which re-renders the open overlay in the pending state and
545
- // reloads its visible range (via __updateCalendars). Also reload the selected month so a
546
- // value can be re-validated even while the overlay is closed.
547
- controller.reset();
548
578
  this.__ensureSelectedDateLoaded();
549
579
  }
550
580
 
@@ -641,8 +671,13 @@ export const DatePickerMixin = (subclass) =>
641
671
  const inputValid = !inputValue || (!!this._selectedDate && inputValue === this.__formatDate(this._selectedDate));
642
672
  const isDateValid =
643
673
  !this._selectedDate ||
644
- (dateAllowed(this._selectedDate, this._minDate, this._maxDate, this.isDateDisabled) &&
645
- !this.__isDateDisabledByProvider(this._selectedDate));
674
+ dateSelectable(
675
+ this._selectedDate,
676
+ this._minDate,
677
+ this._maxDate,
678
+ this.isDateDisabled,
679
+ this._dateMetadataController,
680
+ );
646
681
 
647
682
  let inputValidity = true;
648
683
  if (this.inputElement && this.inputElement.checkValidity) {
@@ -653,90 +688,44 @@ export const DatePickerMixin = (subclass) =>
653
688
  }
654
689
 
655
690
  /**
656
- * Returns true if the given date is known to be disabled by `dateMetadataProvider`. The
657
- * result comes from the controller's cache of already-loaded ranges. The month containing a
658
- * selected date is loaded on demand (see `_selectedDateChanged`), so a value typed while the
659
- * overlay is closed is re-validated once the provider answers (see `__onDateMetadataChanged`).
660
- * 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.
661
694
  * @private
662
695
  */
663
- __isDateDisabledByProvider(date) {
696
+ __ensureSelectedDateLoaded() {
664
697
  const controller = this._dateMetadataController;
665
- return !!controller && !!controller.provider && controller.isDateDisabled(date);
666
- }
667
-
668
- /** @private */
669
- __dateMetadataProviderChanged(dateMetadataProvider) {
670
- // The controller is created in `ready()`; `setProvider` is called there for the initial value.
671
- if (this._dateMetadataController) {
672
- this._dateMetadataController.setProvider(dateMetadataProvider);
673
- this.__ensureSelectedDateLoaded();
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);
674
705
  }
675
706
  }
676
707
 
677
708
  /**
678
- * Asks the controller to resolve the month containing the selected date, so that a value set
679
- * or typed while the overlay is closed can be validated against the provider without opening
680
- * the overlay. When the month resolves, `__onDateMetadataChanged` 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.
681
712
  * @private
682
713
  */
683
- __ensureSelectedDateLoaded() {
684
- const controller = this._dateMetadataController;
685
- if (controller?.provider && this._selectedDate && !controller.isMonthLoaded(this._selectedDate)) {
686
- this.__awaitingProviderValidation = true;
687
- controller.ensureRangeLoaded(this._selectedDate, this._selectedDate);
688
- }
689
- }
690
-
691
- /** @private */
692
714
  __onDateMetadataChanged() {
693
715
  const controller = this._dateMetadataController;
694
- // Push the new loading and cache state to the open overlay so it re-renders the months and
695
- // updates the spinner. When the overlay is closed there is nothing to update.
716
+
717
+ // Only the open overlay has a spinner to update and a today button to re-evaluate.
696
718
  if (this._overlayContent) {
697
- this._overlayContent.loading = controller.loading;
698
- this._overlayContent._dateMetadataVersion += 1;
719
+ this._overlayContent.loading = controller.isLoading();
720
+ this._overlayContent.updateTodayButton();
699
721
  }
700
- // Re-validate once the month containing the selected value has resolved, so a value typed
701
- // 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.
702
725
  if (this.__awaitingProviderValidation && this._selectedDate && controller.isMonthLoaded(this._selectedDate)) {
703
726
  this.__awaitingProviderValidation = false;
704
727
  this._requestValidation();
705
728
  }
706
-
707
- this.__adjustInitialFocusForProvider();
708
- }
709
-
710
- /**
711
- * Moves the overlay's initial focus off a date the provider turns out to disable, once the
712
- * provider has answered for that month. Only touches the auto-picked initial date and only
713
- * while the user has not navigated away, so disabled dates the user focuses on purpose (which
714
- * stay keyboard-focusable) are left alone.
715
- * @private
716
- */
717
- __adjustInitialFocusForProvider() {
718
- const content = this._overlayContent;
719
- const controller = this._dateMetadataController;
720
- const initial = this.__initialFocusDate;
721
- if (!content || !initial || !controller.provider) {
722
- return;
723
- }
724
- // The user has moved focus; stop trying to adjust the initial date.
725
- if (!dateEquals(content.focusedDate, initial)) {
726
- this.__initialFocusDate = null;
727
- return;
728
- }
729
- // Wait until the provider has answered for the initial month.
730
- if (!controller.isMonthLoaded(initial)) {
731
- return;
732
- }
733
- this.__initialFocusDate = null;
734
- if (controller.isDateDisabled(initial)) {
735
- const closest = content.__closestSelectableDate(initial);
736
- if (closest) {
737
- content.focusDate(closest);
738
- }
739
- }
740
729
  }
741
730
 
742
731
  /**
@@ -922,14 +911,12 @@ export const DatePickerMixin = (subclass) =>
922
911
  this._applyInputValue(selectedDate);
923
912
  }
924
913
 
925
- // Preload the provider's answer for the selected month so the value can be validated even
926
- // when the overlay is never opened.
927
- this.__ensureSelectedDateLoaded();
928
-
929
914
  this.value = this._formatISO(selectedDate);
930
915
  this._ignoreFocusedDateChange = true;
931
916
  this._focusedDate = selectedDate;
932
917
  this._ignoreFocusedDateChange = false;
918
+
919
+ this.__ensureSelectedDateLoaded();
933
920
  }
934
921
 
935
922
  /** @private */
@@ -997,12 +984,10 @@ export const DatePickerMixin = (subclass) =>
997
984
  selectedDate,
998
985
  showWeekNumbers,
999
986
  isDateDisabled,
1000
- dateMetadataProvider,
1001
987
  enteredDate,
1002
988
  ) {
1003
989
  if (overlayContent) {
1004
- // Share the date-picker's controller so the overlay renders from the same cache that
1005
- // validation uses. Assigned before the other properties, which trigger `__updateCalendars`.
990
+ // Reuse the date-picker's controller so the overlay shares the same cache.
1006
991
  overlayContent._dateMetadataController = this._dateMetadataController;
1007
992
  overlayContent.i18n = effectiveI18n;
1008
993
  overlayContent.label = label;
@@ -1012,7 +997,6 @@ export const DatePickerMixin = (subclass) =>
1012
997
  overlayContent.selectedDate = selectedDate;
1013
998
  overlayContent.showWeekNumbers = showWeekNumbers;
1014
999
  overlayContent.isDateDisabled = isDateDisabled;
1015
- overlayContent.dateMetadataProvider = dateMetadataProvider;
1016
1000
  overlayContent.enteredDate = enteredDate;
1017
1001
  }
1018
1002
  }
@@ -1020,11 +1004,7 @@ export const DatePickerMixin = (subclass) =>
1020
1004
  /** @private */
1021
1005
  __updateOverlayContentTheme(overlayContent, theme) {
1022
1006
  if (overlayContent) {
1023
- if (theme) {
1024
- overlayContent.setAttribute('theme', theme);
1025
- } else {
1026
- overlayContent.removeAttribute('theme');
1027
- }
1007
+ setOrRemoveAttribute(overlayContent, 'theme', theme);
1028
1008
  }
1029
1009
  }
1030
1010
 
@@ -1061,12 +1041,6 @@ export const DatePickerMixin = (subclass) =>
1061
1041
  content.focusedDate = scrollFocusDate;
1062
1042
  this._ignoreFocusedDateChange = false;
1063
1043
 
1064
- // When opening without a selected value, remember the auto-picked initial date so it can be
1065
- // moved off a provider-disabled date once the provider answers (see __onDateMetadataChanged).
1066
- // A date the user selected themselves is left in place even if the provider disables it.
1067
- this.__initialFocusDate = this._selectedDate ? null : scrollFocusDate;
1068
- this.__adjustInitialFocusForProvider();
1069
-
1070
1044
  window.addEventListener('scroll', this._boundOnScroll, true);
1071
1045
 
1072
1046
  if (this._focusOverlayOnOpen) {
@@ -1130,7 +1104,7 @@ export const DatePickerMixin = (subclass) =>
1130
1104
 
1131
1105
  /** @protected */
1132
1106
  _onOverlayClosed() {
1133
- this.__initialFocusDate = null;
1107
+ this._overlayContent?.cancelLoadVisibleDateMetadata();
1134
1108
 
1135
1109
  // Reset `aria-hidden` state.
1136
1110
  if (this.__showOthers) {
@@ -1150,6 +1124,12 @@ export const DatePickerMixin = (subclass) =>
1150
1124
  if (!this.value && !this._keyboardActive) {
1151
1125
  this._requestValidation();
1152
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
+ }
1153
1133
  }
1154
1134
 
1155
1135
  /** @private */