@vaadin/date-picker 25.3.0-alpha8 → 25.3.0-beta1

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.
@@ -4,6 +4,79 @@
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
6
 
7
+ /**
8
+ * Create a date at midnight in local time. Unlike `new Date(year, month, day)`,
9
+ * this supports years below 100, which the constructor maps into the 20th
10
+ * century. The month is assigned before the day so that the initial day of month
11
+ * (1) always exists in the target month.
12
+ *
13
+ * @param {number} year
14
+ * @param {number} month Zero-based month, may be out of range to shift the year
15
+ * @param {number} day May be `0` to select the last day of the previous month
16
+ * @return {Date}
17
+ */
18
+ export function createDate(year, month, day) {
19
+ const date = new Date(0, 0); // Wrong date (1900-01-01), but with midnight in local time
20
+ date.setFullYear(year);
21
+ date.setMonth(month);
22
+ date.setDate(day);
23
+ return date;
24
+ }
25
+
26
+ /**
27
+ * Get the first day of the month the given date is in.
28
+ *
29
+ * @param {!Date} date
30
+ * @return {Date}
31
+ */
32
+ export function firstOfMonth(date) {
33
+ return createDate(date.getFullYear(), date.getMonth(), 1);
34
+ }
35
+
36
+ /**
37
+ * Get the last day of the month the given date is in.
38
+ *
39
+ * @param {!Date} date
40
+ * @return {Date}
41
+ */
42
+ export function lastOfMonth(date) {
43
+ return createDate(date.getFullYear(), date.getMonth() + 1, 0);
44
+ }
45
+
46
+ /**
47
+ * Get the index of a month, counted from January of year 0. Reduces a month to a single
48
+ * integer, so a lookup builds no key and two months are adjacent when their indexes are.
49
+ *
50
+ * @param {number} year
51
+ * @param {number} month Zero-based month
52
+ * @return {number}
53
+ */
54
+ export function monthIndexOf(year, month) {
55
+ return year * 12 + month;
56
+ }
57
+
58
+ /**
59
+ * Get the index of the month the given date is in.
60
+ *
61
+ * @param {!Date} date
62
+ * @return {number}
63
+ */
64
+ export function monthIndex(date) {
65
+ return monthIndexOf(date.getFullYear(), date.getMonth());
66
+ }
67
+
68
+ /**
69
+ * Get the first day of the month with the given index, inverting `monthIndexOf`. Counting from
70
+ * January of year 0 also inverts negative indexes, since `createDate` normalizes a month outside
71
+ * 0-11 into the year.
72
+ *
73
+ * @param {number} index
74
+ * @return {Date}
75
+ */
76
+ export function monthDate(index) {
77
+ return createDate(0, index, 1);
78
+ }
79
+
7
80
  /**
8
81
  * Get ISO 8601 week number for the given date.
9
82
  *
@@ -106,6 +179,22 @@ export function dateAllowed(date, min, max, isDateDisabled) {
106
179
  return (!min || date >= min) && (!max || date <= max) && !dateIsDisabled;
107
180
  }
108
181
 
182
+ /**
183
+ * Check if the given date can be selected: allowed by `dateAllowed` and not reported as disabled
184
+ * by the date metadata controller. This is narrower than `dateAllowed`, which decides what can be
185
+ * focused: a disabled date is still focusable, it just cannot be selected.
186
+ *
187
+ * @param {!Date} date The date to check
188
+ * @param {Date | null} min Range start
189
+ * @param {Date | null} max Range end
190
+ * @param {function(!DatePickerDate): boolean} isDateDisabled Callback to check if the date is disabled
191
+ * @param {DateMetadataController | null} [controller] The date metadata controller
192
+ * @return {boolean} True if the date can be selected
193
+ */
194
+ export function dateSelectable(date, min, max, isDateDisabled, controller) {
195
+ return dateAllowed(date, min, max, isDateDisabled) && !controller?.isDateDisabled(date);
196
+ }
197
+
109
198
  /**
110
199
  * Get closest date from array of dates.
111
200
  *
@@ -171,51 +260,66 @@ export function getAdjustedYear(referenceDate, year, month = 0, day = 1) {
171
260
  return adjustedYear;
172
261
  }
173
262
 
263
+ const ISO_DATE = /^([-+]\d{1,6}|\d{2,4})-(\d{1,2})-(\d{1,2})$/u;
264
+
265
+ // The parts of a date string in a format the parsers accept, as written.
266
+ function parseParts(str) {
267
+ // Parsing with RegExp to ensure correct format
268
+ const parts = ISO_DATE.exec(str);
269
+ if (!parts) {
270
+ return undefined;
271
+ }
272
+
273
+ return { year: parseInt(parts[1], 10), month: parseInt(parts[2], 10) - 1, day: parseInt(parts[3], 10) };
274
+ }
275
+
174
276
  /**
175
277
  * Parse date string of one of the following date formats:
176
278
  * - ISO 8601 `"YYYY-MM-DD"`
177
- * - 6-digit extended ISO 8601 `"+YYYYYY-MM-DD"`, `"-YYYYYY-MM-DD"`
279
+ * - Extended ISO 8601 with a signed year, e.g. `"+012026-MM-DD"` or `"-0001-MM-DD"`
280
+ *
281
+ * A date that does not exist, such as `"2026-02-30"`, is not parsed. Building it would carry the
282
+ * surplus into the next month or year and answer with a date that was never asked for.
283
+ *
178
284
  * @param {!string} str Date string to parse
179
285
  * @return {Date} Parsed date in system timezone
180
286
  */
181
287
  export function parseDate(str) {
182
- // Parsing with RegExp to ensure correct format
183
- const parts = /^([-+]\d{1}|\d{2,4}|[-+]\d{6})-(\d{1,2})-(\d{1,2})$/u.exec(str);
288
+ const parts = parseParts(str);
184
289
  if (!parts) {
185
290
  return undefined;
186
291
  }
187
292
 
188
- const date = new Date(0, 0); // Wrong date (1900-01-01), but with midnight in local time
189
- date.setFullYear(parseInt(parts[1], 10));
190
- date.setMonth(parseInt(parts[2], 10) - 1);
191
- date.setDate(parseInt(parts[3], 10));
192
- return date;
293
+ const date = createDate(parts.year, parts.month, parts.day);
294
+
295
+ return date.getMonth() === parts.month && date.getDate() === parts.day ? date : undefined;
193
296
  }
194
297
 
195
298
  /**
196
299
  * Parse date string of one of the following date formats:
197
300
  * - ISO 8601 `"YYYY-MM-DD"`
198
- * - 6-digit extended ISO 8601 `"+YYYYYY-MM-DD"`, `"-YYYYYY-MM-DD"`
301
+ * - Extended ISO 8601 with a signed year, e.g. `"+012026-MM-DD"` or `"-0001-MM-DD"`
199
302
  *
200
303
  * Uses UTC date components to allow handling date instances independently of
201
304
  * the system time-zone.
202
305
  *
306
+ * A date that does not exist, such as `"2026-02-30"`, is not parsed, as in `parseDate`.
307
+ *
203
308
  * @param {!string} str Date string to parse
204
309
  * @return {Date} Parsed date in UTC timezone
205
310
  */
206
311
  export function parseUTCDate(str) {
207
- // Parsing with RegExp to ensure correct format
208
- const parts = /^([-+]\d{1}|\d{2,4}|[-+]\d{6})-(\d{1,2})-(\d{1,2})$/u.exec(str);
312
+ const parts = parseParts(str);
209
313
  if (!parts) {
210
314
  return undefined;
211
315
  }
212
316
 
213
317
  const date = new Date(Date.UTC(0, 0)); // Wrong date (1900-01-01), but with midnight in UTC
214
- date.setUTCFullYear(parseInt(parts[1], 10));
215
- date.setUTCMonth(parseInt(parts[2], 10) - 1);
216
- date.setUTCDate(parseInt(parts[3], 10));
318
+ date.setUTCFullYear(parts.year);
319
+ date.setUTCMonth(parts.month);
320
+ date.setUTCDate(parts.day);
217
321
 
218
- return date;
322
+ return date.getUTCMonth() === parts.month && date.getUTCDate() === parts.day ? date : undefined;
219
323
  }
220
324
 
221
325
  function formatISODateBase(dateParts) {
@@ -18,6 +18,49 @@ export interface DatePickerDate {
18
18
  year: number;
19
19
  }
20
20
 
21
+ /**
22
+ * A range of dates that `dateMetadataProvider` is asked about.
23
+ * It can span several months and always covers whole months.
24
+ */
25
+ export interface DatePickerDateRange {
26
+ /**
27
+ * The first date of the range (inclusive), as an ISO 8601 date.
28
+ */
29
+ start: string;
30
+ /**
31
+ * The last date of the range (inclusive), as an ISO 8601 date.
32
+ */
33
+ end: string;
34
+ }
35
+
36
+ /**
37
+ * Metadata for a single date, returned by `dateMetadataProvider`.
38
+ */
39
+ export interface DatePickerDateMetadata {
40
+ /**
41
+ * The date the metadata applies to in ISO 8601 format.
42
+ */
43
+ date: string;
44
+ /**
45
+ * Whether the date cannot be selected.
46
+ */
47
+ disabled?: boolean;
48
+ /**
49
+ * Part names to add to the date, so a theme can style it with `::part()`. A single name, or
50
+ * several separated by spaces. Do not use built-in names like `disabled` and `selected`.
51
+ */
52
+ part?: string;
53
+ }
54
+
55
+ /**
56
+ * A function called with the range of dates the calendar is about to render, returning
57
+ * the metadata for the dates in that range. It can return a `Promise` to load the metadata
58
+ * asynchronously, and `null` or `undefined` when no date in the range has metadata.
59
+ */
60
+ export type DatePickerDateMetadataProvider = (
61
+ range: DatePickerDateRange,
62
+ ) => DatePickerDateMetadata[] | Promise<DatePickerDateMetadata[] | null | undefined> | null | undefined;
63
+
21
64
  export interface DatePickerI18n {
22
65
  /**
23
66
  * An array with the full names of months starting
@@ -47,6 +90,11 @@ export interface DatePickerI18n {
47
90
  * Translation of the Cancel button text.
48
91
  */
49
92
  cancel?: string;
93
+ /**
94
+ * Accessible name of the overlay content, announced by screen readers when
95
+ * the overlay opens.
96
+ */
97
+ dialogAccessibleName?: string;
50
98
  /**
51
99
  * Used for adjusting the year value when parsing dates with short years.
52
100
  * The year values between 0 and 99 are evaluated and adjusted.
@@ -174,6 +222,10 @@ export declare class DatePickerMixinClass {
174
222
  * // Translation of the Cancel button text.
175
223
  * cancel: 'Cancel',
176
224
  *
225
+ * // Accessible name of the overlay content, announced by screen readers
226
+ * // when the overlay opens.
227
+ * dialogAccessibleName: 'Calendar',
228
+ *
177
229
  * // Used for adjusting the year value when parsing dates with short years.
178
230
  * // The year values between 0 and 99 are evaluated and adjusted.
179
231
  * // Example: for a referenceDate of 1970-10-30;
@@ -233,9 +285,52 @@ export declare class DatePickerMixinClass {
233
285
  * A function to be used to determine whether the user can select a given date.
234
286
  * Receives a `DatePickerDate` object of the date to be selected and should return a
235
287
  * boolean.
288
+ *
289
+ * The function is called once per date and has to answer synchronously. Use
290
+ * `dateMetadataProvider` when the answer has to be loaded first, or when dates also need
291
+ * custom part names. A date is disabled when either of the two disables it.
236
292
  */
237
293
  isDateDisabled: (date: DatePickerDate) => boolean;
238
294
 
295
+ /**
296
+ * A function that provides metadata for the dates the calendar is about to render: whether they
297
+ * are disabled, and CSS `part` names for styling from outside using the `::part()` selector.
298
+ * Unlike `isDateDisabled`, which is called once per date, the metadata provider is called for
299
+ * a range of dates at a time, and again as the calendar renders further dates.
300
+ *
301
+ * It receives a `DatePickerDateRange` and returns an array of `DatePickerDateMetadata` objects
302
+ * for the dates in that range that have metadata. It can return a `Promise` to load the metadata
303
+ * asynchronously, and `null` or `undefined` when no date in the range has metadata.
304
+ *
305
+ * The returned array has the following structure:
306
+ *
307
+ * ```js
308
+ * [
309
+ * // The date is an ISO 8601 string.
310
+ * { date: '2026-01-01', disabled: true },
311
+ *
312
+ * // Adds a custom part name to the date.
313
+ * { date: '2026-01-02', part: 'busy' },
314
+ * ]
315
+ * ```
316
+ *
317
+ * A date is disabled if its metadata marks it disabled, or `isDateDisabled` returns `true`, or
318
+ * it is outside `min` and `max`. Disabled dates are not selectable, and typing a disabled date in
319
+ * the field makes it invalid. The provider does not affect which date is focused when opening the
320
+ * overlay. Use `initialPosition` property to provide a selectable date.
321
+ *
322
+ * While a returned `Promise` is pending, the dates it covers are not disabled yet and render with
323
+ * the `loading` part. If the function throws or rejects, corresponding dates are requested again
324
+ * the next time the user navigates.
325
+ *
326
+ * The provider is used for validation also when the overlay is closed. Date is considered valid
327
+ * while the provider is pending, and is re-validated again after the metadata is loaded.
328
+ *
329
+ * Keep a stable reference to the function: assigning a new one clears the cache and re-fetches
330
+ * visible range. Call `clearCache()` to re-fetch when the data behind the same function changed.
331
+ */
332
+ dateMetadataProvider: DatePickerDateMetadataProvider | null | undefined;
333
+
239
334
  /**
240
335
  * Opens the dropdown.
241
336
  */
@@ -245,4 +340,9 @@ export declare class DatePickerMixinClass {
245
340
  * Closes the dropdown.
246
341
  */
247
342
  close(): void;
343
+
344
+ /**
345
+ * Clears the `dateMetadataProvider` cache and reloads the date metadata.
346
+ */
347
+ clearCache(): void;
248
348
  }
@@ -8,13 +8,16 @@ import { DelegateFocusMixin } from '@vaadin/a11y-base/src/delegate-focus-mixin.j
8
8
  import { 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,
@@ -42,6 +45,7 @@ export const datePickerI18nDefaults = Object.freeze({
42
45
  firstDayOfWeek: 0,
43
46
  today: 'Today',
44
47
  cancel: 'Cancel',
48
+ dialogAccessibleName: 'Calendar',
45
49
  referenceDate: '',
46
50
  formatDate(d) {
47
51
  const yearStr = String(d.year).replace(/\d+/u, (y) => '0000'.substr(y.length) + y);
@@ -59,7 +63,7 @@ export const datePickerI18nDefaults = Object.freeze({
59
63
  date = parseInt(parts[1]);
60
64
  year = parseInt(parts[2]);
61
65
  if (parts[2].length < 3 && year >= 0) {
62
- const usedReferenceDate = this.referenceDate ? parseDate(this.referenceDate) : new Date();
66
+ const usedReferenceDate = parseDate(this.referenceDate) || new Date();
63
67
  year = getAdjustedYear(usedReferenceDate, year, month, date);
64
68
  }
65
69
  } else if (parts.length === 2) {
@@ -202,12 +206,59 @@ export const DatePickerMixin = (subclass) =>
202
206
  * Receives a `DatePickerDate` object of the date to be selected and should return a
203
207
  * boolean.
204
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
+ *
205
213
  * @type {function(DatePickerDate): boolean | undefined}
206
214
  */
207
215
  isDateDisabled: {
208
216
  type: Function,
209
217
  },
210
218
 
219
+ /**
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
+ * ```
240
+ *
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.
245
+ *
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.
249
+ *
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}
257
+ */
258
+ dateMetadataProvider: {
259
+ type: Function,
260
+ },
261
+
211
262
  /**
212
263
  * The earliest date that can be selected. All earlier dates will be disabled.
213
264
  * @type {Date | undefined}
@@ -272,7 +323,7 @@ export const DatePickerMixin = (subclass) =>
272
323
  }
273
324
 
274
325
  static get constraints() {
275
- return [...super.constraints, 'min', 'max'];
326
+ return [...super.constraints, 'min', 'max', 'dateMetadataProvider'];
276
327
  }
277
328
 
278
329
  constructor() {
@@ -280,6 +331,9 @@ export const DatePickerMixin = (subclass) =>
280
331
 
281
332
  this._boundOnClick = this._onClick.bind(this);
282
333
  this._boundOnScroll = this._onScroll.bind(this);
334
+
335
+ this._dateMetadataController = new DateMetadataController(this, () => this.__onDateMetadataChanged());
336
+ this.addController(this._dateMetadataController);
283
337
  }
284
338
 
285
339
  /**
@@ -322,6 +376,10 @@ export const DatePickerMixin = (subclass) =>
322
376
  * // Translation of the Cancel button text.
323
377
  * cancel: 'Cancel',
324
378
  *
379
+ * // Accessible name of the overlay content, announced by screen readers
380
+ * // when the overlay opens.
381
+ * dialogAccessibleName: 'Calendar',
382
+ *
325
383
  * // Used for adjusting the year value when parsing dates with short years.
326
384
  * // The year values between 0 and 99 are evaluated and adjusted.
327
385
  * // Example: for a referenceDate of 1970-10-30;
@@ -401,7 +459,10 @@ export const DatePickerMixin = (subclass) =>
401
459
  super._onFocus(event);
402
460
 
403
461
  if (this._noInput && !isKeyboardActive()) {
462
+ // Blur to hide the virtual keyboard, but do not validate.
463
+ this.__ignoreInternalBlur = true;
404
464
  event.target.blur();
465
+ this.__ignoreInternalBlur = false;
405
466
  }
406
467
  }
407
468
 
@@ -412,6 +473,10 @@ export const DatePickerMixin = (subclass) =>
412
473
  _onBlur(event) {
413
474
  super._onBlur(event);
414
475
 
476
+ if (this.__ignoreInternalBlur) {
477
+ return;
478
+ }
479
+
415
480
  if (!this.opened) {
416
481
  this.__commitParsedOrFocusedDate();
417
482
 
@@ -444,6 +509,11 @@ export const DatePickerMixin = (subclass) =>
444
509
  updated(props) {
445
510
  super.updated(props);
446
511
 
512
+ if (props.has('dateMetadataProvider')) {
513
+ this._dateMetadataController.setProvider(this.dateMetadataProvider);
514
+ this.__reloadDateMetadata();
515
+ }
516
+
447
517
  if (props.has('showWeekNumbers') || props.has('__effectiveI18n')) {
448
518
  // Currently only supported for locales that start the week on Monday.
449
519
  this.toggleAttribute('week-numbers', this.showWeekNumbers && this.__effectiveI18n.firstDayOfWeek === 1);
@@ -486,6 +556,28 @@ export const DatePickerMixin = (subclass) =>
486
556
  this.$.overlay.close();
487
557
  }
488
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
+
489
581
  /** @private */
490
582
  __ensureContent() {
491
583
  if (this._overlayContent) {
@@ -578,7 +670,14 @@ export const DatePickerMixin = (subclass) =>
578
670
  const inputValue = this._inputElementValue;
579
671
  const inputValid = !inputValue || (!!this._selectedDate && inputValue === this.__formatDate(this._selectedDate));
580
672
  const isDateValid =
581
- !this._selectedDate || dateAllowed(this._selectedDate, this._minDate, this._maxDate, this.isDateDisabled);
673
+ !this._selectedDate ||
674
+ dateSelectable(
675
+ this._selectedDate,
676
+ this._minDate,
677
+ this._maxDate,
678
+ this.isDateDisabled,
679
+ this._dateMetadataController,
680
+ );
582
681
 
583
682
  let inputValidity = true;
584
683
  if (this.inputElement && this.inputElement.checkValidity) {
@@ -588,6 +687,47 @@ export const DatePickerMixin = (subclass) =>
588
687
  return inputValid && isDateValid && inputValidity;
589
688
  }
590
689
 
690
+ /**
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.
694
+ * @private
695
+ */
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);
705
+ }
706
+ }
707
+
708
+ /**
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.
712
+ * @private
713
+ */
714
+ __onDateMetadataChanged() {
715
+ const controller = this._dateMetadataController;
716
+
717
+ // Only the open overlay has a spinner to update and a today button to re-evaluate.
718
+ if (this._overlayContent) {
719
+ this._overlayContent.loading = controller.isLoading();
720
+ this._overlayContent.updateTodayButton();
721
+ }
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.
725
+ if (this.__awaitingProviderValidation && this._selectedDate && controller.isMonthLoaded(this._selectedDate)) {
726
+ this.__awaitingProviderValidation = false;
727
+ this._requestValidation();
728
+ }
729
+ }
730
+
591
731
  /**
592
732
  * Override method inherited from `FocusMixin`
593
733
  * to not call `_setFocused(true)` when focus
@@ -775,6 +915,8 @@ export const DatePickerMixin = (subclass) =>
775
915
  this._ignoreFocusedDateChange = true;
776
916
  this._focusedDate = selectedDate;
777
917
  this._ignoreFocusedDateChange = false;
918
+
919
+ this.__ensureSelectedDateLoaded();
778
920
  }
779
921
 
780
922
  /** @private */
@@ -845,6 +987,8 @@ export const DatePickerMixin = (subclass) =>
845
987
  enteredDate,
846
988
  ) {
847
989
  if (overlayContent) {
990
+ // Reuse the date-picker's controller so the overlay shares the same cache.
991
+ overlayContent._dateMetadataController = this._dateMetadataController;
848
992
  overlayContent.i18n = effectiveI18n;
849
993
  overlayContent.label = label;
850
994
  overlayContent.minDate = minDate;
@@ -860,11 +1004,7 @@ export const DatePickerMixin = (subclass) =>
860
1004
  /** @private */
861
1005
  __updateOverlayContentTheme(overlayContent, theme) {
862
1006
  if (overlayContent) {
863
- if (theme) {
864
- overlayContent.setAttribute('theme', theme);
865
- } else {
866
- overlayContent.removeAttribute('theme');
867
- }
1007
+ setOrRemoveAttribute(overlayContent, 'theme', theme);
868
1008
  }
869
1009
  }
870
1010
 
@@ -964,6 +1104,8 @@ export const DatePickerMixin = (subclass) =>
964
1104
 
965
1105
  /** @protected */
966
1106
  _onOverlayClosed() {
1107
+ this._overlayContent?.cancelLoadVisibleDateMetadata();
1108
+
967
1109
  // Reset `aria-hidden` state.
968
1110
  if (this.__showOthers) {
969
1111
  this.__showOthers();