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

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.
@@ -18,6 +18,44 @@ export interface DatePickerDate {
18
18
  year: number;
19
19
  }
20
20
 
21
+ /**
22
+ * A range of dates the provider is asked about. Always covers whole months: `start` is the first
23
+ * day of a month and `end` the last day of a month, so a provider may group its query by month.
24
+ */
25
+ export interface DatePickerDateRange {
26
+ /**
27
+ * The first date of the range (inclusive).
28
+ */
29
+ start: DatePickerDate;
30
+ /**
31
+ * The last date of the range (inclusive).
32
+ */
33
+ end: DatePickerDate;
34
+ }
35
+
36
+ /**
37
+ * Metadata resolved on demand for a single date.
38
+ */
39
+ export interface DatePickerDateMetadata extends DatePickerDate {
40
+ /**
41
+ * Whether the date cannot be selected.
42
+ */
43
+ disabled?: boolean;
44
+ /**
45
+ * Part names to add to the date, so a theme can style it with `::part()`. A single name, or
46
+ * several separated by spaces. Do not use built-in names like `disabled` and `selected`.
47
+ */
48
+ part?: string;
49
+ }
50
+
51
+ /**
52
+ * A function called with the range of dates the calendar is about to show, returning or resolving
53
+ * with the metadata for the dates in that range.
54
+ */
55
+ export type DatePickerDateMetadataProvider = (
56
+ range: DatePickerDateRange,
57
+ ) => DatePickerDateMetadata[] | Promise<DatePickerDateMetadata[] | null | undefined> | null | undefined;
58
+
21
59
  export interface DatePickerI18n {
22
60
  /**
23
61
  * An array with the full names of months starting
@@ -47,6 +85,11 @@ export interface DatePickerI18n {
47
85
  * Translation of the Cancel button text.
48
86
  */
49
87
  cancel?: string;
88
+ /**
89
+ * Accessible name of the overlay content, announced by screen readers when
90
+ * the overlay opens.
91
+ */
92
+ dialogAccessibleName?: string;
50
93
  /**
51
94
  * Used for adjusting the year value when parsing dates with short years.
52
95
  * The year values between 0 and 99 are evaluated and adjusted.
@@ -174,6 +217,10 @@ export declare class DatePickerMixinClass {
174
217
  * // Translation of the Cancel button text.
175
218
  * cancel: 'Cancel',
176
219
  *
220
+ * // Accessible name of the overlay content, announced by screen readers
221
+ * // when the overlay opens.
222
+ * dialogAccessibleName: 'Calendar',
223
+ *
177
224
  * // Used for adjusting the year value when parsing dates with short years.
178
225
  * // The year values between 0 and 99 are evaluated and adjusted.
179
226
  * // Example: for a referenceDate of 1970-10-30;
@@ -236,6 +283,44 @@ export declare class DatePickerMixinClass {
236
283
  */
237
284
  isDateDisabled: (date: DatePickerDate) => boolean;
238
285
 
286
+ /**
287
+ * A batch function that fetches metadata for a range of dates the calendar is about to
288
+ * render. It receives a `DatePickerDateRange` and returns, or resolves with, an array of
289
+ * `DatePickerDateMetadata` objects — a `DatePickerDate` extended with metadata such as
290
+ * `disabled`, e.g. `{ year, month, day, disabled: true }` — for the dates that have metadata
291
+ * within that range. Dates it does not mention have no metadata. `month` is 0-based: 0 is
292
+ * January and 11 is December.
293
+ *
294
+ * Unlike `isDateDisabled`, which is called once per date, this function is called for a range of
295
+ * dates at a time, and again as the calendar renders further dates. The size of the range is
296
+ * decided by the calendar and may span several months, and may include months it already has
297
+ * metadata for, whose entries are then ignored.
298
+ *
299
+ * It may return a `Promise`, so the answer can come from a server. Until it resolves, the
300
+ * affected dates render with the `loading` part but stay selectable, and a loading spinner is
301
+ * shown. Nothing is disabled before the provider has actually reported it, so a slow provider
302
+ * does not make the calendar unusable. If it throws or rejects, the error is logged and the
303
+ * affected months are requested again the next time the user navigates.
304
+ *
305
+ * `disabled` from the metadata is combined with `min`, `max` and `isDateDisabled`: a date is
306
+ * disabled if it is out of the min/max range, or `isDateDisabled` returns `true`, or its metadata
307
+ * marks it disabled. That decides what the calendar renders as disabled, what can be selected, and
308
+ * whether the field is valid.
309
+ *
310
+ * A value is checked against the provider even if the overlay is never opened, which loads the
311
+ * month holding it. Until that month answers the value is valid, and it is re-validated once the
312
+ * answer arrives, so `checkValidity()` can report a value as valid and then invalid.
313
+ *
314
+ * `part` from the metadata adds part names to the date, so a theme can style specific dates with
315
+ * `::part()` — e.g. `{ year, month, day, part: 'busy' }`. Give a single name or several separated
316
+ * by spaces. Do not use built-in names like `disabled` and `selected`.
317
+ *
318
+ * Keep a stable reference to the function. Assigning a new function clears the cache and
319
+ * re-fetches every visible range. To re-fetch while keeping the same function, because the data
320
+ * behind it changed, call `clearCache()`.
321
+ */
322
+ dateMetadataProvider: DatePickerDateMetadataProvider | null | undefined;
323
+
239
324
  /**
240
325
  * Opens the dropdown.
241
326
  */
@@ -245,4 +330,9 @@ export declare class DatePickerMixinClass {
245
330
  * Closes the dropdown.
246
331
  */
247
332
  close(): void;
333
+
334
+ /**
335
+ * Clears the `dateMetadataProvider` cache and reloads the date metadata.
336
+ */
337
+ clearCache(): void;
248
338
  }
@@ -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);
@@ -208,6 +212,48 @@ export const DatePickerMixin = (subclass) =>
208
212
  type: Function,
209
213
  },
210
214
 
215
+ /**
216
+ * A batch function that fetches metadata for a range of dates the calendar is about to
217
+ * render. It receives a `DatePickerDateRange` and returns, or resolves with, an array of
218
+ * `DatePickerDateMetadata` objects — a `DatePickerDate` extended with metadata such as
219
+ * `disabled`, e.g. `{ year, month, day, disabled: true }` — for the dates that have metadata
220
+ * within that range. Dates it does not mention have no metadata. `month` is 0-based: 0 is
221
+ * January and 11 is December.
222
+ *
223
+ * Unlike `isDateDisabled`, which is called once per date, this function is called for a
224
+ * range of dates at a time, and again as the calendar renders further dates. The size of the
225
+ * range is decided by the calendar and may span several months, and may include months it
226
+ * already has metadata for, whose entries are then ignored.
227
+ *
228
+ * It may return a `Promise`, so the answer can come from a server. Until it resolves, the
229
+ * affected dates render with the `loading` part but stay selectable, and a loading spinner
230
+ * is shown. Nothing is disabled before the provider has actually reported it, so a slow
231
+ * provider does not make the calendar unusable. If it throws or rejects, the error is logged
232
+ * and the affected months are requested again the next time the user navigates.
233
+ *
234
+ * `disabled` from the metadata is combined with `min`, `max` and `isDateDisabled`: a date is
235
+ * disabled if it is out of the min/max range, or `isDateDisabled` returns `true`, or its
236
+ * metadata marks it disabled. That decides what the calendar renders as disabled, what can be
237
+ * selected, and whether the field is valid.
238
+ *
239
+ * A value is checked against the provider even if the overlay is never opened, which loads the
240
+ * month holding it. Until that month answers the value is valid, and it is re-validated once
241
+ * the answer arrives, so `checkValidity()` can report a value as valid and then invalid.
242
+ *
243
+ * `part` from the metadata adds part names to the date, so a theme can style specific dates
244
+ * with `::part()` — e.g. `{ year, month, day, part: 'busy' }`. Give a single name or several
245
+ * separated by spaces. Do not use built-in names like `disabled` and `selected`.
246
+ *
247
+ * Keep a stable reference to the function. Assigning a new function clears the cache and
248
+ * re-fetches every visible range. To re-fetch while keeping the same function, because the
249
+ * data behind it changed, call `clearCache()`.
250
+ *
251
+ * @type {DatePickerDateMetadataProvider | null | undefined}
252
+ */
253
+ dateMetadataProvider: {
254
+ type: Function,
255
+ },
256
+
211
257
  /**
212
258
  * The earliest date that can be selected. All earlier dates will be disabled.
213
259
  * @type {Date | undefined}
@@ -272,7 +318,7 @@ export const DatePickerMixin = (subclass) =>
272
318
  }
273
319
 
274
320
  static get constraints() {
275
- return [...super.constraints, 'min', 'max'];
321
+ return [...super.constraints, 'min', 'max', 'dateMetadataProvider'];
276
322
  }
277
323
 
278
324
  constructor() {
@@ -280,6 +326,9 @@ export const DatePickerMixin = (subclass) =>
280
326
 
281
327
  this._boundOnClick = this._onClick.bind(this);
282
328
  this._boundOnScroll = this._onScroll.bind(this);
329
+
330
+ this._dateMetadataController = new DateMetadataController(this, () => this.__onDateMetadataChanged());
331
+ this.addController(this._dateMetadataController);
283
332
  }
284
333
 
285
334
  /**
@@ -322,6 +371,10 @@ export const DatePickerMixin = (subclass) =>
322
371
  * // Translation of the Cancel button text.
323
372
  * cancel: 'Cancel',
324
373
  *
374
+ * // Accessible name of the overlay content, announced by screen readers
375
+ * // when the overlay opens.
376
+ * dialogAccessibleName: 'Calendar',
377
+ *
325
378
  * // Used for adjusting the year value when parsing dates with short years.
326
379
  * // The year values between 0 and 99 are evaluated and adjusted.
327
380
  * // Example: for a referenceDate of 1970-10-30;
@@ -444,6 +497,11 @@ export const DatePickerMixin = (subclass) =>
444
497
  updated(props) {
445
498
  super.updated(props);
446
499
 
500
+ if (props.has('dateMetadataProvider')) {
501
+ this._dateMetadataController.setProvider(this.dateMetadataProvider);
502
+ this.__reloadDateMetadata();
503
+ }
504
+
447
505
  if (props.has('showWeekNumbers') || props.has('__effectiveI18n')) {
448
506
  // Currently only supported for locales that start the week on Monday.
449
507
  this.toggleAttribute('week-numbers', this.showWeekNumbers && this.__effectiveI18n.firstDayOfWeek === 1);
@@ -486,6 +544,28 @@ export const DatePickerMixin = (subclass) =>
486
544
  this.$.overlay.close();
487
545
  }
488
546
 
547
+ /**
548
+ * Clears the `dateMetadataProvider` cache and reloads the date metadata.
549
+ */
550
+ clearCache() {
551
+ this._dateMetadataController.clearCache();
552
+ this.__reloadDateMetadata();
553
+ }
554
+
555
+ /**
556
+ * Asks for what the dropped cache was holding: the months the overlay is showing, and the month
557
+ * of the value being validated. Requested from here rather than from the controller's
558
+ * notification, which would turn a provider that keeps failing into an endless retry, since a
559
+ * failed month is dropped and so becomes missing again.
560
+ * @private
561
+ */
562
+ __reloadDateMetadata() {
563
+ if (this.opened) {
564
+ this._overlayContent?.loadVisibleDateMetadata();
565
+ }
566
+ this.__ensureSelectedDateLoaded();
567
+ }
568
+
489
569
  /** @private */
490
570
  __ensureContent() {
491
571
  if (this._overlayContent) {
@@ -578,7 +658,14 @@ export const DatePickerMixin = (subclass) =>
578
658
  const inputValue = this._inputElementValue;
579
659
  const inputValid = !inputValue || (!!this._selectedDate && inputValue === this.__formatDate(this._selectedDate));
580
660
  const isDateValid =
581
- !this._selectedDate || dateAllowed(this._selectedDate, this._minDate, this._maxDate, this.isDateDisabled);
661
+ !this._selectedDate ||
662
+ dateSelectable(
663
+ this._selectedDate,
664
+ this._minDate,
665
+ this._maxDate,
666
+ this.isDateDisabled,
667
+ this._dateMetadataController,
668
+ );
582
669
 
583
670
  let inputValidity = true;
584
671
  if (this.inputElement && this.inputElement.checkValidity) {
@@ -588,6 +675,47 @@ export const DatePickerMixin = (subclass) =>
588
675
  return inputValid && isDateValid && inputValidity;
589
676
  }
590
677
 
678
+ /**
679
+ * Asks the controller for the month holding the selected date, so a value that was set or typed
680
+ * without ever opening the overlay is still checked against the provider. Validation is re-run
681
+ * from the host callback once the month resolves.
682
+ * @private
683
+ */
684
+ __ensureSelectedDateLoaded() {
685
+ const controller = this._dateMetadataController;
686
+ const awaiting = !!(controller?.provider && this._selectedDate && !controller.isMonthLoaded(this._selectedDate));
687
+ // Always assigned, so clearing the value or removing the provider while a request is in
688
+ // flight disarms the pending re-validation, and a later answer for some other month does not
689
+ // re-validate a value that never waited for it.
690
+ this.__awaitingProviderValidation = awaiting;
691
+ if (awaiting) {
692
+ controller.ensureRangeLoaded(this._selectedDate, this._selectedDate);
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Called by the date metadata controller, one microtask after its state changed
698
+ * and coalesced, so this never writes reactive state from inside an update. The
699
+ * rendered months refresh on their own because they subscribe to the controller.
700
+ * @private
701
+ */
702
+ __onDateMetadataChanged() {
703
+ const controller = this._dateMetadataController;
704
+
705
+ // Only the open overlay has a spinner to update and a today button to re-evaluate.
706
+ if (this._overlayContent) {
707
+ this._overlayContent.loading = controller.isLoading();
708
+ this._overlayContent.updateTodayButton();
709
+ }
710
+
711
+ // Runs whether or not the overlay was ever opened, which is the case this exists for: a value
712
+ // set or typed with the overlay closed is reported invalid as soon as its month answers.
713
+ if (this.__awaitingProviderValidation && this._selectedDate && controller.isMonthLoaded(this._selectedDate)) {
714
+ this.__awaitingProviderValidation = false;
715
+ this._requestValidation();
716
+ }
717
+ }
718
+
591
719
  /**
592
720
  * Override method inherited from `FocusMixin`
593
721
  * to not call `_setFocused(true)` when focus
@@ -775,6 +903,8 @@ export const DatePickerMixin = (subclass) =>
775
903
  this._ignoreFocusedDateChange = true;
776
904
  this._focusedDate = selectedDate;
777
905
  this._ignoreFocusedDateChange = false;
906
+
907
+ this.__ensureSelectedDateLoaded();
778
908
  }
779
909
 
780
910
  /** @private */
@@ -845,6 +975,8 @@ export const DatePickerMixin = (subclass) =>
845
975
  enteredDate,
846
976
  ) {
847
977
  if (overlayContent) {
978
+ // Reuse the date-picker's controller so the overlay shares the same cache.
979
+ overlayContent._dateMetadataController = this._dateMetadataController;
848
980
  overlayContent.i18n = effectiveI18n;
849
981
  overlayContent.label = label;
850
982
  overlayContent.minDate = minDate;
@@ -860,11 +992,7 @@ export const DatePickerMixin = (subclass) =>
860
992
  /** @private */
861
993
  __updateOverlayContentTheme(overlayContent, theme) {
862
994
  if (overlayContent) {
863
- if (theme) {
864
- overlayContent.setAttribute('theme', theme);
865
- } else {
866
- overlayContent.removeAttribute('theme');
867
- }
995
+ setOrRemoveAttribute(overlayContent, 'theme', theme);
868
996
  }
869
997
  }
870
998
 
@@ -964,6 +1092,8 @@ export const DatePickerMixin = (subclass) =>
964
1092
 
965
1093
  /** @protected */
966
1094
  _onOverlayClosed() {
1095
+ this._overlayContent?.cancelLoadVisibleDateMetadata();
1096
+
967
1097
  // Reset `aria-hidden` state.
968
1098
  if (this.__showOthers) {
969
1099
  this.__showOthers();
@@ -5,10 +5,22 @@
5
5
  */
6
6
  import { timeOut } from '@vaadin/component-base/src/async.js';
7
7
  import { Debouncer } from '@vaadin/component-base/src/debounce.js';
8
+ import { setOrRemoveAttribute } from '@vaadin/component-base/src/dom-utils.js';
8
9
  import { addListener } from '@vaadin/component-base/src/gestures.js';
9
10
  import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js';
10
11
  import { SlotController } from '@vaadin/component-base/src/slot-controller.js';
11
- import { dateAfterXMonths, dateAllowed, dateEquals, getClosestDate } from './vaadin-date-picker-helper.js';
12
+ import {
13
+ createDate,
14
+ dateAfterXMonths,
15
+ dateAllowed,
16
+ dateEquals,
17
+ dateSelectable,
18
+ firstOfMonth,
19
+ getClosestDate,
20
+ lastOfMonth,
21
+ monthDate,
22
+ monthIndex,
23
+ } from './vaadin-date-picker-helper.js';
12
24
 
13
25
  export const DatePickerOverlayContentMixin = (superClass) =>
14
26
  class DatePickerOverlayContentMixin extends superClass {
@@ -106,6 +118,17 @@ export const DatePickerOverlayContentMixin = (superClass) =>
106
118
  type: Function,
107
119
  },
108
120
 
121
+ /**
122
+ * Reflected while data is being loaded, so the overlay can show a loading spinner.
123
+ * Currently set while the date metadata provider is resolving.
124
+ * @protected
125
+ */
126
+ loading: {
127
+ type: Boolean,
128
+ value: false,
129
+ reflectToAttribute: true,
130
+ },
131
+
109
132
  enteredDate: {
110
133
  type: Date,
111
134
  sync: true,
@@ -124,6 +147,18 @@ export const DatePickerOverlayContentMixin = (superClass) =>
124
147
  type: Object,
125
148
  },
126
149
 
150
+ /**
151
+ * The date-picker's date metadata controller, assigned by the host. Declared as a property,
152
+ * and a dependency of `__updateCalendarsConfig` below, so that the calendars are handed it
153
+ * and subscribed whenever it arrives, rather than relying on the host assigning it before
154
+ * the properties that happen to trigger that observer.
155
+ * @protected
156
+ */
157
+ _dateMetadataController: {
158
+ type: Object,
159
+ sync: true,
160
+ },
161
+
127
162
  calendars: {
128
163
  type: Array,
129
164
  value: () => [],
@@ -138,14 +173,47 @@ export const DatePickerOverlayContentMixin = (superClass) =>
138
173
 
139
174
  static get observers() {
140
175
  return [
141
- '__updateCalendarsConfig(calendars, i18n, minDate, maxDate, showWeekNumbers, isDateDisabled, _theme)',
176
+ '__updateCalendarsConfig(calendars, i18n, minDate, maxDate, showWeekNumbers, isDateDisabled, _theme, _dateMetadataController)',
142
177
  '__updateCalendarsState(calendars, selectedDate, focusedDate, enteredDate, _ignoreTaps)',
143
178
  '__updateCancelButton(_cancelButton, i18n)',
144
- '__updateTodayButton(_todayButton, i18n, minDate, maxDate, isDateDisabled)',
145
179
  '__updateYears(years, selectedDate, _theme)',
146
180
  ];
147
181
  }
148
182
 
183
+ /** @protected */
184
+ disconnectedCallback() {
185
+ super.disconnectedCallback();
186
+
187
+ this.cancelLoadVisibleDateMetadata();
188
+ }
189
+
190
+ /** @protected */
191
+ updated(props) {
192
+ super.updated(props);
193
+
194
+ if (props.has('loading')) {
195
+ setOrRemoveAttribute(this, 'aria-busy', this.loading);
196
+ }
197
+
198
+ if (props.has('i18n')) {
199
+ setOrRemoveAttribute(this, 'aria-label', this.i18n?.dialogAccessibleName);
200
+ }
201
+
202
+ if (props.has('calendars') || props.has('_dateMetadataController')) {
203
+ this.loadVisibleDateMetadata();
204
+ }
205
+
206
+ if (
207
+ props.has('_todayButton') ||
208
+ props.has('i18n') ||
209
+ props.has('minDate') ||
210
+ props.has('maxDate') ||
211
+ props.has('isDateDisabled')
212
+ ) {
213
+ this.updateTodayButton();
214
+ }
215
+ }
216
+
149
217
  /**
150
218
  * Whether to scroll to a sub-month position when scrolling to a date.
151
219
  * This is active if the month scroller is not large enough to fit a
@@ -292,14 +360,44 @@ export const DatePickerOverlayContentMixin = (superClass) =>
292
360
  }
293
361
  }
294
362
 
295
- /** @private */
296
- __updateTodayButton(todayButton, i18n, minDate, maxDate, isDateDisabled) {
363
+ /**
364
+ * Applies the today button's label and whether today can be selected.
365
+ */
366
+ updateTodayButton() {
367
+ const todayButton = this._todayButton;
297
368
  if (todayButton) {
298
- todayButton.textContent = i18n?.today;
299
- todayButton.disabled = !this._isTodayAllowed(minDate, maxDate, isDateDisabled);
369
+ todayButton.textContent = this.i18n?.today;
370
+ todayButton.disabled = !this._isTodayAllowed();
300
371
  }
301
372
  }
302
373
 
374
+ /**
375
+ * Requests the date metadata for the months currently rendered. Months already loaded or in flight
376
+ * are skipped.
377
+ */
378
+ loadVisibleDateMetadata() {
379
+ const controller = this._dateMetadataController;
380
+ if (!controller) {
381
+ return;
382
+ }
383
+ // Reduced to month indexes so the outermost months can be picked with plain arithmetic.
384
+ const indexes = (this.calendars ?? [])
385
+ .map((calendar) => calendar.month)
386
+ .filter(Boolean)
387
+ .map((month) => monthIndex(month));
388
+ if (indexes.length === 0) {
389
+ return;
390
+ }
391
+ controller.ensureRangeLoaded(monthDate(Math.min(...indexes)), monthDate(Math.max(...indexes)));
392
+ }
393
+
394
+ /**
395
+ * Drops a date metadata load that navigating has scheduled but that has not run yet.
396
+ */
397
+ cancelLoadVisibleDateMetadata() {
398
+ this._loadDateMetadataDebouncer?.cancel();
399
+ }
400
+
303
401
  /**
304
402
  * Config that changes rarely (locale, allowed range, week numbers, theme).
305
403
  * Split from the interaction state below so a keyboard/selection update
@@ -307,24 +405,43 @@ export const DatePickerOverlayContentMixin = (superClass) =>
307
405
  * @private
308
406
  */
309
407
  // eslint-disable-next-line @typescript-eslint/max-params
310
- __updateCalendarsConfig(calendars, i18n, minDate, maxDate, showWeekNumbers, isDateDisabled, theme) {
408
+ __updateCalendarsConfig(
409
+ calendars,
410
+ i18n,
411
+ minDate,
412
+ maxDate,
413
+ showWeekNumbers,
414
+ isDateDisabled,
415
+ theme,
416
+ dateMetadataController,
417
+ ) {
311
418
  if (calendars?.length) {
312
419
  calendars.forEach((calendar) => {
313
420
  calendar.i18n = i18n;
314
421
  calendar.minDate = minDate;
315
422
  calendar.maxDate = maxDate;
316
423
  calendar.isDateDisabled = isDateDisabled;
424
+ calendar._dateMetadataController = dateMetadataController;
425
+ // Subscribe to controller updates (subscribing again is a no-op).
426
+ dateMetadataController?.subscribe(calendar);
317
427
  calendar.showWeekNumbers = showWeekNumbers;
318
428
 
319
- if (theme) {
320
- calendar.setAttribute('theme', theme);
321
- } else {
322
- calendar.removeAttribute('theme');
323
- }
429
+ setOrRemoveAttribute(calendar, 'theme', theme);
324
430
  });
325
431
  }
326
432
  }
327
433
 
434
+ /**
435
+ * Debounced variant used while navigating, so a continuous scroll triggers one load once it
436
+ * settles instead of a request per intermediate position.
437
+ * @private
438
+ */
439
+ __scheduleLoadVisibleDateMetadata() {
440
+ this._loadDateMetadataDebouncer = Debouncer.debounce(this._loadDateMetadataDebouncer, timeOut.after(200), () =>
441
+ this.loadVisibleDateMetadata(),
442
+ );
443
+ }
444
+
328
445
  /**
329
446
  * Interaction state that changes often (focus, selection, entered date, taps).
330
447
  * @private
@@ -346,11 +463,7 @@ export const DatePickerOverlayContentMixin = (superClass) =>
346
463
  years.forEach((year) => {
347
464
  year.selectedDate = selectedDate;
348
465
 
349
- if (theme) {
350
- year.setAttribute('theme', theme);
351
- } else {
352
- year.removeAttribute('theme');
353
- }
466
+ setOrRemoveAttribute(year, 'theme', theme);
354
467
  });
355
468
  }
356
469
  }
@@ -360,7 +473,7 @@ export const DatePickerOverlayContentMixin = (superClass) =>
360
473
  * @protected
361
474
  */
362
475
  _selectDate(dateToSelect) {
363
- if (!this._dateAllowed(dateToSelect)) {
476
+ if (!this._dateSelectable(dateToSelect)) {
364
477
  return false;
365
478
  }
366
479
  this.selectedDate = dateToSelect;
@@ -426,11 +539,7 @@ export const DatePickerOverlayContentMixin = (superClass) =>
426
539
  * @private
427
540
  */
428
541
  _calculateWeekScrollOffset(date) {
429
- // Get first day of month
430
- const temp = new Date(0, 0);
431
- temp.setFullYear(date.getFullYear());
432
- temp.setMonth(date.getMonth());
433
- temp.setDate(1);
542
+ const temp = firstOfMonth(date);
434
543
  // Determine week (=row index) of date within the month
435
544
  let week = 0;
436
545
  while (temp.getDate() < date.getDate()) {
@@ -458,12 +567,14 @@ export const DatePickerOverlayContentMixin = (superClass) =>
458
567
  const monthPosition = this._monthScroller.position;
459
568
  this._visibleMonthIndex = Math.floor(monthPosition);
460
569
  this._yearScroller.position = (monthPosition + this._originDate.getMonth()) / 12;
570
+ this.__scheduleLoadVisibleDateMetadata();
461
571
  }
462
572
 
463
573
  /** @private */
464
574
  _repositionMonthScroller() {
465
575
  this._monthScroller.position = this._yearScroller.position * 12 - this._originDate.getMonth();
466
576
  this._visibleMonthIndex = Math.floor(this._monthScroller.position);
577
+ this.__scheduleLoadVisibleDateMetadata();
467
578
  }
468
579
 
469
580
  /** @private */
@@ -630,8 +741,7 @@ export const DatePickerOverlayContentMixin = (superClass) =>
630
741
 
631
742
  /** @private */
632
743
  _differenceInMonths(date1, date2) {
633
- const months = (date1.getFullYear() - date2.getFullYear()) * 12;
634
- return months - date2.getMonth() + date1.getMonth();
744
+ return monthIndex(date1) - monthIndex(date2);
635
745
  }
636
746
 
637
747
  /** @private */
@@ -859,13 +969,11 @@ export const DatePickerOverlayContentMixin = (superClass) =>
859
969
 
860
970
  /** @private */
861
971
  _getDateDiff(months, days) {
862
- const date = new Date(0, 0);
863
- date.setFullYear(this.focusedDate.getFullYear());
864
- date.setMonth(this.focusedDate.getMonth() + months);
865
- if (days) {
866
- date.setDate(this.focusedDate.getDate() + days);
867
- }
868
- return date;
972
+ return createDate(
973
+ this.focusedDate.getFullYear(),
974
+ this.focusedDate.getMonth() + months,
975
+ days ? this.focusedDate.getDate() + days : 1,
976
+ );
869
977
  }
870
978
 
871
979
  /** @private */
@@ -895,16 +1003,7 @@ export const DatePickerOverlayContentMixin = (superClass) =>
895
1003
 
896
1004
  /** @private */
897
1005
  _moveFocusInsideMonth(focusedDate, property) {
898
- const dateToFocus = new Date(0, 0);
899
- dateToFocus.setFullYear(focusedDate.getFullYear());
900
-
901
- if (property === 'minDate') {
902
- dateToFocus.setMonth(focusedDate.getMonth());
903
- dateToFocus.setDate(1);
904
- } else {
905
- dateToFocus.setMonth(focusedDate.getMonth() + 1);
906
- dateToFocus.setDate(0);
907
- }
1006
+ const dateToFocus = property === 'minDate' ? firstOfMonth(focusedDate) : lastOfMonth(focusedDate);
908
1007
 
909
1008
  if (this._dateAllowed(dateToFocus)) {
910
1009
  this.focusDate(dateToFocus);
@@ -923,17 +1022,18 @@ export const DatePickerOverlayContentMixin = (superClass) =>
923
1022
  }
924
1023
 
925
1024
  /** @private */
926
- _isTodayAllowed(min, max, isDateDisabled) {
927
- return this._dateAllowed(this._getTodayMidnight(), min, max, isDateDisabled);
1025
+ _dateSelectable(date) {
1026
+ return dateSelectable(date, this.minDate, this.maxDate, this.isDateDisabled, this._dateMetadataController);
1027
+ }
1028
+
1029
+ /** @private */
1030
+ _isTodayAllowed() {
1031
+ return this._dateSelectable(this._getTodayMidnight());
928
1032
  }
929
1033
 
930
1034
  /** @private */
931
1035
  _getTodayMidnight() {
932
1036
  const today = new Date();
933
- const todayMidnight = new Date(0, 0);
934
- todayMidnight.setFullYear(today.getFullYear());
935
- todayMidnight.setMonth(today.getMonth());
936
- todayMidnight.setDate(today.getDate());
937
- return todayMidnight;
1037
+ return createDate(today.getFullYear(), today.getMonth(), today.getDate());
938
1038
  }
939
1039
  };