@vaadin/date-picker 25.3.0-alpha6 → 25.3.0-dev.1fa5a51482

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,254 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2016 - 2026 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import { extractDateParts } from './vaadin-date-picker-helper.js';
7
+
8
+ /**
9
+ * Number of months fetched before and after the requested range, so that
10
+ * scrolling a few months in either direction does not trigger a new request.
11
+ * Mirrors how the grid prefetches rows around the viewport.
12
+ */
13
+ const PREFETCH_MONTHS = 6;
14
+
15
+ function monthKey(date) {
16
+ return `${date.getFullYear()}-${date.getMonth()}`;
17
+ }
18
+
19
+ function dateKey(date) {
20
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
21
+ }
22
+
23
+ function firstOfMonth(date) {
24
+ return new Date(date.getFullYear(), date.getMonth(), 1);
25
+ }
26
+
27
+ function lastOfMonth(date) {
28
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0);
29
+ }
30
+
31
+ function addMonths(date, months) {
32
+ return new Date(date.getFullYear(), date.getMonth() + months, 1);
33
+ }
34
+
35
+ /**
36
+ * A reactive controller that resolves the metadata (disabled state, custom part
37
+ * names, ...) for the dates shown by the date-picker's `dateMetadataProvider`.
38
+ * It calls the provider once for a range of months (never one date at a time),
39
+ * caches the resolved months so scrolling back and forth does not re-fetch them,
40
+ * and prefetches a buffer of months around the requested range. While any
41
+ * request is in flight, {@link #loading} is `true` so the overlay can show a
42
+ * spinner.
43
+ *
44
+ * The provider may return an array synchronously or a `Promise`, so results
45
+ * from a server (Flow) or a remote availability service can be awaited. Each
46
+ * returned entry is a `DatePickerDate` extended with metadata fields, e.g.
47
+ * `{ year, month, day, disabled: true, part: 'busy' }`.
48
+ */
49
+ export class DateMetadataController {
50
+ constructor(host, onChange) {
51
+ this.host = host;
52
+ this.__onChange = onChange;
53
+ this.provider = null;
54
+ this.__metadata = new Map();
55
+ this.__loadedMonths = new Set();
56
+ this.__pendingMonths = new Set();
57
+ this.__requestId = 0;
58
+ }
59
+
60
+ hostDisconnected() {
61
+ // Invalidate in-flight requests so their late results don't touch a
62
+ // detached host, and drop the pending (loading) state. The resolved cache
63
+ // is kept so reopening the overlay does not re-fetch.
64
+ this.__requestId += 1;
65
+ this.__pendingMonths = new Set();
66
+ }
67
+
68
+ /**
69
+ * Whether any month range is currently being fetched.
70
+ * @return {boolean}
71
+ */
72
+ get loading() {
73
+ return this.__pendingMonths.size > 0;
74
+ }
75
+
76
+ /**
77
+ * Sets the provider function and clears the cache. Passing the same provider
78
+ * again is a no-op, so the cache survives unrelated re-renders — callers
79
+ * should therefore keep a stable provider reference rather than passing a new
80
+ * function on every update, which would otherwise reset the cache and
81
+ * re-fetch every visible range.
82
+ */
83
+ setProvider(provider) {
84
+ if (this.provider === provider) {
85
+ return;
86
+ }
87
+ this.provider = provider;
88
+ this.reset();
89
+ }
90
+
91
+ /** Clears the cache and invalidates any in-flight requests. */
92
+ reset() {
93
+ this.__metadata = new Map();
94
+ this.__loadedMonths = new Set();
95
+ this.__pendingMonths = new Set();
96
+ this.__requestId += 1;
97
+ this.__notify();
98
+ }
99
+
100
+ /**
101
+ * Whether the month containing the given date has been fully resolved.
102
+ * @param {Date} date
103
+ * @return {boolean}
104
+ */
105
+ isMonthLoaded(date) {
106
+ return this.__loadedMonths.has(monthKey(date));
107
+ }
108
+
109
+ /**
110
+ * The metadata resolved for the given date, or `undefined` when the date has
111
+ * no metadata or its month has not been resolved yet.
112
+ * @param {Date} date
113
+ * @return {object | undefined}
114
+ */
115
+ getMetadata(date) {
116
+ return date ? this.__metadata.get(dateKey(date)) : undefined;
117
+ }
118
+
119
+ /**
120
+ * Whether the given date is disabled by the provider. Only returns `true` for
121
+ * dates in an already-resolved month.
122
+ * @param {Date} date
123
+ * @return {boolean}
124
+ */
125
+ isDateDisabled(date) {
126
+ return !!this.getMetadata(date)?.disabled;
127
+ }
128
+
129
+ /**
130
+ * Whether the given date cannot be selected yet: it is disabled by the
131
+ * provider, or its month has not been resolved so its state is still unknown.
132
+ * Returns `false` when no provider is set. Used to block selection and to
133
+ * render dates as non-selectable while their month is loading.
134
+ * @param {Date} date
135
+ * @return {boolean}
136
+ */
137
+ isDateBlocked(date) {
138
+ return !!this.provider && !!date && (this.isDateDisabled(date) || !this.isMonthLoaded(date));
139
+ }
140
+
141
+ /**
142
+ * Ensures the provider has been consulted for the inclusive range between the
143
+ * given dates, expanded by a prefetch buffer of months on each side. Months
144
+ * that are already loaded or in flight are skipped, and consecutive missing
145
+ * months are grouped into a single provider call.
146
+ *
147
+ * @param {Date} startDate
148
+ * @param {Date} endDate
149
+ */
150
+ ensureRangeLoaded(startDate, endDate) {
151
+ if (!this.provider || !startDate || !endDate) {
152
+ return;
153
+ }
154
+
155
+ const first = addMonths(firstOfMonth(startDate), -PREFETCH_MONTHS);
156
+ const last = addMonths(firstOfMonth(endDate), PREFETCH_MONTHS);
157
+
158
+ const monthsToLoad = [];
159
+ for (let month = first; month <= last; month = addMonths(month, 1)) {
160
+ const key = monthKey(month);
161
+ if (!this.__loadedMonths.has(key) && !this.__pendingMonths.has(key)) {
162
+ monthsToLoad.push(new Date(month));
163
+ }
164
+ }
165
+
166
+ if (monthsToLoad.length === 0) {
167
+ return;
168
+ }
169
+
170
+ this.__groupConsecutiveMonths(monthsToLoad).forEach((group) => this.__loadGroup(group));
171
+ }
172
+
173
+ /** @private */
174
+ __groupConsecutiveMonths(months) {
175
+ const groups = [];
176
+ months.forEach((month) => {
177
+ const group = groups.at(-1);
178
+ const previous = group?.at(-1);
179
+ if (previous && monthKey(addMonths(previous, 1)) === monthKey(month)) {
180
+ group.push(month);
181
+ } else {
182
+ groups.push([month]);
183
+ }
184
+ });
185
+ return groups;
186
+ }
187
+
188
+ /** @private */
189
+ __loadGroup(months) {
190
+ const requestId = this.__requestId;
191
+ months.forEach((month) => this.__pendingMonths.add(monthKey(month)));
192
+ this.__notify();
193
+
194
+ const range = {
195
+ start: extractDateParts(firstOfMonth(months[0])),
196
+ end: extractDateParts(lastOfMonth(months[months.length - 1])),
197
+ };
198
+
199
+ let result;
200
+ try {
201
+ result = this.provider(range);
202
+ } catch (error) {
203
+ // Treat a throwing provider the same as a rejected promise: clear the
204
+ // pending state and disable nothing, rather than letting the error
205
+ // propagate out of the scroll/render path that triggered the load.
206
+ console.error(error);
207
+ this.__resolveGroup(months, [], requestId);
208
+ return;
209
+ }
210
+
211
+ if (result && typeof result.then === 'function') {
212
+ result.then(
213
+ (dates) => this.__resolveGroup(months, dates, requestId),
214
+ (error) => {
215
+ console.error(error);
216
+ this.__resolveGroup(months, [], requestId);
217
+ },
218
+ );
219
+ } else {
220
+ this.__resolveGroup(months, result, requestId);
221
+ }
222
+ }
223
+
224
+ /** @private */
225
+ __resolveGroup(months, entries, requestId) {
226
+ // Ignore results from before the last reset (e.g. provider changed).
227
+ if (requestId !== this.__requestId) {
228
+ return;
229
+ }
230
+
231
+ if (Array.isArray(entries)) {
232
+ entries.forEach((entry) => {
233
+ if (entry) {
234
+ this.__metadata.set(`${entry.year}-${entry.month}-${entry.day}`, entry);
235
+ }
236
+ });
237
+ }
238
+
239
+ months.forEach((month) => {
240
+ const key = monthKey(month);
241
+ this.__pendingMonths.delete(key);
242
+ this.__loadedMonths.add(key);
243
+ });
244
+
245
+ this.__notify();
246
+ }
247
+
248
+ /** @private */
249
+ __notify() {
250
+ if (this.__onChange) {
251
+ this.__onChange();
252
+ }
253
+ }
254
+ }
@@ -18,6 +18,34 @@ export interface DatePickerDate {
18
18
  year: number;
19
19
  }
20
20
 
21
+ export interface DatePickerDateRange {
22
+ /**
23
+ * The first date of the range (inclusive).
24
+ */
25
+ start: DatePickerDate;
26
+ /**
27
+ * The last date of the range (inclusive).
28
+ */
29
+ end: DatePickerDate;
30
+ }
31
+
32
+ /**
33
+ * Metadata resolved on demand for a single date by `dateMetadataProvider`. Extends
34
+ * `DatePickerDate` with the date's metadata. Extra fields can be added and used by the
35
+ * synchronous generators (e.g. `isDateDisabled`) as context.
36
+ */
37
+ export interface DatePickerDateMetadata extends DatePickerDate {
38
+ /**
39
+ * Whether the date cannot be selected.
40
+ */
41
+ disabled?: boolean;
42
+ /**
43
+ * Custom part name(s) added to the date cell's `part` attribute, so a theme can style the
44
+ * date via `::part()`. Either a single name or several separated by spaces.
45
+ */
46
+ part?: string;
47
+ }
48
+
21
49
  export interface DatePickerI18n {
22
50
  /**
23
51
  * An array with the full names of months starting
@@ -236,6 +264,31 @@ export declare class DatePickerMixinClass {
236
264
  */
237
265
  isDateDisabled: (date: DatePickerDate) => boolean;
238
266
 
267
+ /**
268
+ * A batch function that fetches metadata for a range of dates the calendar is about to render.
269
+ * It receives a `DatePickerDateRange` object and returns, or resolves with, an array of
270
+ * `DatePickerDateMetadata` objects (a `DatePickerDate` extended with metadata such as
271
+ * `disabled` and custom `part` names) for the dates that have metadata within that range.
272
+ *
273
+ * Unlike `isDateDisabled`, which is called once per date, this function is called for a range
274
+ * of dates at a time, and again as the calendar renders further dates. The size of the range
275
+ * is decided by the calendar and may span multiple months. It may return a `Promise`, in which
276
+ * case the affected dates render in a non-selectable pending state until it resolves.
277
+ *
278
+ * `disabled` from the metadata is combined with `min`, `max` and `isDateDisabled`: a date is
279
+ * disabled if it is out of range, or `isDateDisabled` returns `true`, or its metadata marks it
280
+ * disabled. `part` names are added to the date cell so a theme can style it via `::part()`.
281
+ *
282
+ * Both `disabled` and `part` are returned by this one function, rather than by separate
283
+ * generators, so a single backend query (for example in Flow) can answer both at once instead
284
+ * of being split into two passes over the same data.
285
+ *
286
+ * Keep a stable reference to the function. Assigning a new function resets the internal cache
287
+ * and re-fetches every visible range. To reload after the underlying data changes while keeping
288
+ * the same function, call `clearCache()`.
289
+ */
290
+ dateMetadataProvider: (range: DatePickerDateRange) => DatePickerDateMetadata[] | Promise<DatePickerDateMetadata[]>;
291
+
239
292
  /**
240
293
  * Opens the dropdown.
241
294
  */
@@ -245,4 +298,11 @@ export declare class DatePickerMixinClass {
245
298
  * Closes the dropdown.
246
299
  */
247
300
  close(): void;
301
+
302
+ /**
303
+ * Clears the cached date metadata and reloads it from `dateMetadataProvider`. Call this when the
304
+ * data behind the provider has changed (for example a date became booked) so the calendar
305
+ * reflects it, without having to replace the provider function.
306
+ */
307
+ clearCache(): void;
248
308
  }
@@ -12,6 +12,7 @@ import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js';
12
12
  import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js';
13
13
  import { InputConstraintsMixin } from '@vaadin/field-base/src/input-constraints-mixin.js';
14
14
  import { VirtualKeyboardController } from '@vaadin/field-base/src/virtual-keyboard-controller.js';
15
+ import { DateMetadataController } from './vaadin-date-metadata-controller.js';
15
16
  import {
16
17
  dateAllowed,
17
18
  dateEquals,
@@ -208,6 +209,39 @@ export const DatePickerMixin = (subclass) =>
208
209
  type: Function,
209
210
  },
210
211
 
212
+ /**
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.
219
+ *
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.
225
+ *
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()`.
230
+ *
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.
234
+ *
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()`.
238
+ *
239
+ * @type {function(DatePickerDateRange): Array<DatePickerDateMetadata> | Promise<Array<DatePickerDateMetadata>> | undefined}
240
+ */
241
+ dateMetadataProvider: {
242
+ type: Function,
243
+ },
244
+
211
245
  /**
212
246
  * The earliest date that can be selected. All earlier dates will be disabled.
213
247
  * @type {Date | undefined}
@@ -261,7 +295,8 @@ export const DatePickerMixin = (subclass) =>
261
295
  return [
262
296
  '_selectedDateChanged(_selectedDate, __effectiveI18n)',
263
297
  '_focusedDateChanged(_focusedDate, __effectiveI18n)',
264
- '__updateOverlayContent(_overlayContent, __effectiveI18n, label, _minDate, _maxDate, _focusedDate, _selectedDate, showWeekNumbers, isDateDisabled, __enteredDate)',
298
+ '__updateOverlayContent(_overlayContent, __effectiveI18n, label, _minDate, _maxDate, _focusedDate, _selectedDate, showWeekNumbers, isDateDisabled, dateMetadataProvider, __enteredDate)',
299
+ '__dateMetadataProviderChanged(dateMetadataProvider)',
265
300
  '__updateOverlayContentTheme(_overlayContent, _theme)',
266
301
  '__updateOverlayContentFullScreen(_overlayContent, _fullscreen)',
267
302
  ];
@@ -437,6 +472,13 @@ export const DatePickerMixin = (subclass) =>
437
472
 
438
473
  this.addController(new VirtualKeyboardController(this));
439
474
 
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
+
440
482
  this._overlayElement = this.$.overlay;
441
483
  }
442
484
 
@@ -486,6 +528,26 @@ export const DatePickerMixin = (subclass) =>
486
528
  this.$.overlay.close();
487
529
  }
488
530
 
531
+ /**
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.
538
+ */
539
+ clearCache() {
540
+ const controller = this._dateMetadataController;
541
+ if (!controller) {
542
+ return;
543
+ }
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
+ this.__ensureSelectedDateLoaded();
549
+ }
550
+
489
551
  /** @private */
490
552
  __ensureContent() {
491
553
  if (this._overlayContent) {
@@ -578,7 +640,9 @@ export const DatePickerMixin = (subclass) =>
578
640
  const inputValue = this._inputElementValue;
579
641
  const inputValid = !inputValue || (!!this._selectedDate && inputValue === this.__formatDate(this._selectedDate));
580
642
  const isDateValid =
581
- !this._selectedDate || dateAllowed(this._selectedDate, this._minDate, this._maxDate, this.isDateDisabled);
643
+ !this._selectedDate ||
644
+ (dateAllowed(this._selectedDate, this._minDate, this._maxDate, this.isDateDisabled) &&
645
+ !this.__isDateDisabledByProvider(this._selectedDate));
582
646
 
583
647
  let inputValidity = true;
584
648
  if (this.inputElement && this.inputElement.checkValidity) {
@@ -588,6 +652,93 @@ export const DatePickerMixin = (subclass) =>
588
652
  return inputValid && isDateValid && inputValidity;
589
653
  }
590
654
 
655
+ /**
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.
661
+ * @private
662
+ */
663
+ __isDateDisabledByProvider(date) {
664
+ 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();
674
+ }
675
+ }
676
+
677
+ /**
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.
681
+ * @private
682
+ */
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
+ __onDateMetadataChanged() {
693
+ 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.
696
+ if (this._overlayContent) {
697
+ this._overlayContent.loading = controller.loading;
698
+ this._overlayContent._dateMetadataVersion += 1;
699
+ }
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.
702
+ if (this.__awaitingProviderValidation && this._selectedDate && controller.isMonthLoaded(this._selectedDate)) {
703
+ this.__awaitingProviderValidation = false;
704
+ this._requestValidation();
705
+ }
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
+ }
741
+
591
742
  /**
592
743
  * Override method inherited from `FocusMixin`
593
744
  * to not call `_setFocused(true)` when focus
@@ -771,6 +922,10 @@ export const DatePickerMixin = (subclass) =>
771
922
  this._applyInputValue(selectedDate);
772
923
  }
773
924
 
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
+
774
929
  this.value = this._formatISO(selectedDate);
775
930
  this._ignoreFocusedDateChange = true;
776
931
  this._focusedDate = selectedDate;
@@ -842,9 +997,13 @@ export const DatePickerMixin = (subclass) =>
842
997
  selectedDate,
843
998
  showWeekNumbers,
844
999
  isDateDisabled,
1000
+ dateMetadataProvider,
845
1001
  enteredDate,
846
1002
  ) {
847
1003
  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`.
1006
+ overlayContent._dateMetadataController = this._dateMetadataController;
848
1007
  overlayContent.i18n = effectiveI18n;
849
1008
  overlayContent.label = label;
850
1009
  overlayContent.minDate = minDate;
@@ -853,6 +1012,7 @@ export const DatePickerMixin = (subclass) =>
853
1012
  overlayContent.selectedDate = selectedDate;
854
1013
  overlayContent.showWeekNumbers = showWeekNumbers;
855
1014
  overlayContent.isDateDisabled = isDateDisabled;
1015
+ overlayContent.dateMetadataProvider = dateMetadataProvider;
856
1016
  overlayContent.enteredDate = enteredDate;
857
1017
  }
858
1018
  }
@@ -901,6 +1061,12 @@ export const DatePickerMixin = (subclass) =>
901
1061
  content.focusedDate = scrollFocusDate;
902
1062
  this._ignoreFocusedDateChange = false;
903
1063
 
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
+
904
1070
  window.addEventListener('scroll', this._boundOnScroll, true);
905
1071
 
906
1072
  if (this._focusOverlayOnOpen) {
@@ -964,6 +1130,8 @@ export const DatePickerMixin = (subclass) =>
964
1130
 
965
1131
  /** @protected */
966
1132
  _onOverlayClosed() {
1133
+ this.__initialFocusDate = null;
1134
+
967
1135
  // Reset `aria-hidden` state.
968
1136
  if (this.__showOthers) {
969
1137
  this.__showOthers();