@vaadin/date-picker 25.3.0-alpha3 → 25.3.0-dev.3a3c2d7d2a

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.
@@ -106,6 +106,37 @@ export const DatePickerOverlayContentMixin = (superClass) =>
106
106
  type: Function,
107
107
  },
108
108
 
109
+ /**
110
+ * A batch function that is consulted for a range of dates the calendar is about to render
111
+ * and returns, or resolves with, an array of `DatePickerDate` objects to disable.
112
+ *
113
+ * @type {function(DatePickerDateRange): Array<DatePickerDate> | Promise<Array<DatePickerDate>> | undefined}
114
+ */
115
+ disabledDatesProvider: {
116
+ type: Function,
117
+ },
118
+
119
+ /**
120
+ * Reflected while data is being loaded, so the overlay can show a loading spinner.
121
+ * Currently set while the disabled dates provider is resolving.
122
+ */
123
+ loading: {
124
+ type: Boolean,
125
+ value: false,
126
+ reflectToAttribute: true,
127
+ },
128
+
129
+ /**
130
+ * Bumped whenever the disabled dates controller's cache or loading state changes, to
131
+ * re-run `__updateCalendars` and push the new state to the rendered months.
132
+ * @private
133
+ */
134
+ _disabledDatesVersion: {
135
+ type: Number,
136
+ value: 0,
137
+ attribute: false,
138
+ },
139
+
109
140
  enteredDate: {
110
141
  type: Date,
111
142
  sync: true,
@@ -138,9 +169,10 @@ export const DatePickerOverlayContentMixin = (superClass) =>
138
169
 
139
170
  static get observers() {
140
171
  return [
141
- '__updateCalendars(calendars, i18n, minDate, maxDate, selectedDate, focusedDate, showWeekNumbers, _ignoreTaps, _theme, isDateDisabled, enteredDate)',
172
+ '__updateCalendars(calendars, i18n, minDate, maxDate, selectedDate, focusedDate, showWeekNumbers, _ignoreTaps, _theme, isDateDisabled, _disabledDatesVersion, enteredDate)',
173
+ '__loadingChanged(loading)',
142
174
  '__updateCancelButton(_cancelButton, i18n)',
143
- '__updateTodayButton(_todayButton, i18n, minDate, maxDate, isDateDisabled)',
175
+ '__updateTodayButton(_todayButton, i18n, minDate, maxDate, isDateDisabled, _disabledDatesVersion)',
144
176
  '__updateYears(years, selectedDate, _theme)',
145
177
  ];
146
178
  }
@@ -299,6 +331,17 @@ export const DatePickerOverlayContentMixin = (superClass) =>
299
331
  }
300
332
  }
301
333
 
334
+ /** @private */
335
+ __loadingChanged(loading) {
336
+ // Mark the calendar region as busy while data is being loaded so assistive technology knows
337
+ // the content is still updating.
338
+ if (loading) {
339
+ this.setAttribute('aria-busy', 'true');
340
+ } else {
341
+ this.removeAttribute('aria-busy');
342
+ }
343
+ }
344
+
302
345
  // eslint-disable-next-line @typescript-eslint/max-params
303
346
  __updateCalendars(
304
347
  calendars,
@@ -311,6 +354,7 @@ export const DatePickerOverlayContentMixin = (superClass) =>
311
354
  ignoreTaps,
312
355
  theme,
313
356
  isDateDisabled,
357
+ disabledDatesVersion,
314
358
  enteredDate,
315
359
  ) {
316
360
  if (calendars?.length) {
@@ -319,6 +363,8 @@ export const DatePickerOverlayContentMixin = (superClass) =>
319
363
  calendar.minDate = minDate;
320
364
  calendar.maxDate = maxDate;
321
365
  calendar.isDateDisabled = isDateDisabled;
366
+ calendar.disabledDatesController = this._disabledDatesController;
367
+ calendar.__disabledDatesVersion = disabledDatesVersion;
322
368
  calendar.focusedDate = focusedDate;
323
369
  calendar.selectedDate = selectedDate;
324
370
  calendar.showWeekNumbers = showWeekNumbers;
@@ -331,7 +377,46 @@ export const DatePickerOverlayContentMixin = (superClass) =>
331
377
  calendar.removeAttribute('theme');
332
378
  }
333
379
  });
380
+
381
+ this.__ensureVisibleDisabledDatesLoaded();
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Asks the disabled dates controller to load the range of months currently rendered by the
387
+ * scroller. The controller expands the range with a prefetch buffer and skips months that are
388
+ * already loaded or in flight, so one request covers the whole visible range.
389
+ * @private
390
+ */
391
+ __ensureVisibleDisabledDatesLoaded() {
392
+ const controller = this._disabledDatesController;
393
+ if (!controller?.provider || !this.calendars || this.calendars.length === 0) {
394
+ return;
334
395
  }
396
+ const indexes = this.calendars
397
+ .map((calendar) => calendar.month)
398
+ .filter(Boolean)
399
+ .map((month) => month.getFullYear() * 12 + month.getMonth());
400
+ if (indexes.length === 0) {
401
+ return;
402
+ }
403
+ const min = Math.min(...indexes);
404
+ const max = Math.max(...indexes);
405
+ controller.ensureRangeLoaded(
406
+ new Date(Math.floor(min / 12), min % 12, 1),
407
+ new Date(Math.floor(max / 12), max % 12, 1),
408
+ );
409
+ }
410
+
411
+ /**
412
+ * Debounced variant used on scroll, so a continuous scroll only triggers one load once it
413
+ * settles instead of a request per intermediate position.
414
+ * @private
415
+ */
416
+ __scheduleVisibleDisabledDatesLoad() {
417
+ this._loadDisabledDatesDebouncer = Debouncer.debounce(this._loadDisabledDatesDebouncer, timeOut.after(200), () =>
418
+ this.__ensureVisibleDisabledDatesLoaded(),
419
+ );
335
420
  }
336
421
 
337
422
  /** @private */
@@ -357,6 +442,11 @@ export const DatePickerOverlayContentMixin = (superClass) =>
357
442
  if (!this._dateAllowed(dateToSelect)) {
358
443
  return false;
359
444
  }
445
+ // Block dates disabled by the provider, or in a month whose provider result has not loaded
446
+ // yet, so they cannot be selected via keyboard or the today button either.
447
+ if (this._disabledDatesController?.isDateBlocked(dateToSelect)) {
448
+ return false;
449
+ }
360
450
  this.selectedDate = dateToSelect;
361
451
  this.dispatchEvent(
362
452
  new CustomEvent('date-selected', { detail: { date: dateToSelect }, bubbles: true, composed: true }),
@@ -452,12 +542,17 @@ export const DatePickerOverlayContentMixin = (superClass) =>
452
542
  const monthPosition = this._monthScroller.position;
453
543
  this._visibleMonthIndex = Math.floor(monthPosition);
454
544
  this._yearScroller.position = (monthPosition + this._originDate.getMonth()) / 12;
545
+ // Called from every navigation path (month scroll, year click, reveal), so this is the
546
+ // single place to keep the disabled dates loaded for the newly visible months. Debounced
547
+ // so a continuous scroll only triggers one load once it settles.
548
+ this.__scheduleVisibleDisabledDatesLoad();
455
549
  }
456
550
 
457
551
  /** @private */
458
552
  _repositionMonthScroller() {
459
553
  this._monthScroller.position = this._yearScroller.position * 12 - this._originDate.getMonth();
460
554
  this._visibleMonthIndex = Math.floor(this._monthScroller.position);
555
+ this.__scheduleVisibleDisabledDatesLoad();
461
556
  }
462
557
 
463
558
  /** @private */
@@ -831,6 +926,33 @@ export const DatePickerOverlayContentMixin = (superClass) =>
831
926
  this.focusDate(getClosestDate(focus, [this.minDate, this.maxDate]));
832
927
  }
833
928
 
929
+ /**
930
+ * Returns the nearest date to the given one that can actually be selected: inside min/max, not
931
+ * disabled by `isDateDisabled`, and not disabled by a resolved `disabledDatesProvider`. Scans a
932
+ * year in both directions and returns `undefined` if none is found, so the caller can leave the
933
+ * focus untouched.
934
+ * @private
935
+ */
936
+ __closestSelectableDate(date) {
937
+ const controller = this._disabledDatesController;
938
+ const isSelectable = (candidate) =>
939
+ this._dateAllowed(candidate) && !(controller?.provider && controller.isDateDisabled(candidate));
940
+
941
+ if (isSelectable(date)) {
942
+ return date;
943
+ }
944
+ for (let offset = 1; offset <= 366; offset++) {
945
+ for (const direction of [1, -1]) {
946
+ const candidate = new Date(date);
947
+ candidate.setDate(candidate.getDate() + offset * direction);
948
+ if (isSelectable(candidate)) {
949
+ return candidate;
950
+ }
951
+ }
952
+ }
953
+ return undefined;
954
+ }
955
+
834
956
  /** @private */
835
957
  _focusAllowedDate(dateToFocus, diff, keepMonth) {
836
958
  // For this check we do consider the isDateDisabled function because disabled dates are allowed to be focused, just not outside min/max
@@ -918,7 +1040,12 @@ export const DatePickerOverlayContentMixin = (superClass) =>
918
1040
 
919
1041
  /** @private */
920
1042
  _isTodayAllowed(min, max, isDateDisabled) {
921
- return this._dateAllowed(this._getTodayMidnight(), min, max, isDateDisabled);
1043
+ const today = this._getTodayMidnight();
1044
+ if (!this._dateAllowed(today, min, max, isDateDisabled)) {
1045
+ return false;
1046
+ }
1047
+ // Today is not selectable while it is disabled by the provider or its month is still loading.
1048
+ return !this._disabledDatesController?.isDateBlocked(today);
922
1049
  }
923
1050
 
924
1051
  /** @private */
@@ -12,6 +12,7 @@ import { html, LitElement } from 'lit';
12
12
  import { defineCustomElement } from '@vaadin/component-base/src/define.js';
13
13
  import { DirMixin } from '@vaadin/component-base/src/dir-mixin.js';
14
14
  import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
15
+ import { loaderStyles } from '@vaadin/component-base/src/styles/loader-styles.js';
15
16
  import { LumoInjectionMixin } from '@vaadin/vaadin-themable-mixin/lumo-injection-mixin.js';
16
17
  import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
17
18
  import { overlayContentStyles } from './styles/vaadin-date-picker-overlay-content-base-styles.js';
@@ -30,7 +31,7 @@ class DatePickerOverlayContent extends DatePickerOverlayContentMixin(
30
31
  }
31
32
 
32
33
  static get styles() {
33
- return overlayContentStyles;
34
+ return [loaderStyles, overlayContentStyles];
34
35
  }
35
36
 
36
37
  static get lumoInjector() {
@@ -43,6 +44,8 @@ class DatePickerOverlayContent extends DatePickerOverlayContentMixin(
43
44
  <slot name="months"></slot>
44
45
  <slot name="years"></slot>
45
46
 
47
+ <div part="loader" aria-hidden="true"></div>
48
+
46
49
  <div role="toolbar" part="toolbar">
47
50
  <slot name="today-button"></slot>
48
51
  <div
@@ -7,7 +7,7 @@ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
7
7
  import { InputControlMixin } from '@vaadin/field-base/src/input-control-mixin.js';
8
8
  import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
9
9
  import { DatePickerMixin } from './vaadin-date-picker-mixin.js';
10
- export { DatePickerDate, DatePickerI18n } from './vaadin-date-picker-mixin.js';
10
+ export { DatePickerDate, DatePickerDateRange, DatePickerI18n } from './vaadin-date-picker-mixin.js';
11
11
 
12
12
  /**
13
13
  * Fired when the user commits a value change.
@@ -92,6 +92,7 @@ import { DatePickerMixin } from './vaadin-date-picker-mixin.js';
92
92
  * ----------------------|--------------------
93
93
  * `years-toggle-button` | Fullscreen mode years scroller toggle
94
94
  * `toolbar` | Toolbar with slotted buttons
95
+ * `loader` | Loading spinner shown while data is being loaded, for example while the disabled dates provider resolves
95
96
  *
96
97
  * The following state attributes are available on the `<vaadin-date-picker-overlay-content>` element:
97
98
  *
@@ -100,6 +101,7 @@ import { DatePickerMixin } from './vaadin-date-picker-mixin.js';
100
101
  * `desktop` | Set when the overlay content is in desktop mode
101
102
  * `fullscreen` | Set when the overlay content is in fullscreen mode
102
103
  * `years-visible` | Set when the year scroller is visible in fullscreen mode
104
+ * `loading` | Set while data is being loaded, for example while the disabled dates provider resolves
103
105
  *
104
106
  * In order to style the month calendar, use `<vaadin-month-calendar>` shadow DOM parts:
105
107
  *
@@ -112,6 +114,7 @@ import { DatePickerMixin } from './vaadin-date-picker-mixin.js';
112
114
  * `week-number` | Week number element
113
115
  * `date` | Date element
114
116
  * `disabled` | Disabled date element
117
+ * `pending` | Date element in a month whose disabled dates provider result is still loading
115
118
  * `focused` | Focused date element
116
119
  * `selected` | Selected date element
117
120
  * `today` | Date element corresponding to the current day
@@ -0,0 +1,66 @@
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 type { ReactiveController, ReactiveControllerHost } from 'lit';
7
+ import type { DatePickerDate, DatePickerDateRange } from './vaadin-date-picker-mixin.js';
8
+
9
+ export type DisabledDatesProvider = (range: DatePickerDateRange) => DatePickerDate[] | Promise<DatePickerDate[]>;
10
+
11
+ /**
12
+ * A reactive controller that resolves the dates disabled by the date-picker's
13
+ * `disabledDatesProvider`. It calls the provider once for a range of months,
14
+ * caches the resolved months so scrolling back and forth does not re-fetch,
15
+ * and prefetches a buffer of months around the requested range.
16
+ */
17
+ export declare class DisabledDatesController implements ReactiveController {
18
+ constructor(host: ReactiveControllerHost, onChange: () => void);
19
+
20
+ /**
21
+ * The provider function, or `null` when none is set.
22
+ */
23
+ provider: DisabledDatesProvider | null;
24
+
25
+ /**
26
+ * Whether any month range is currently being fetched.
27
+ */
28
+ readonly loading: boolean;
29
+
30
+ hostDisconnected(): void;
31
+
32
+ /**
33
+ * Sets the provider function and clears the cache. Passing the same provider
34
+ * again is a no-op. Callers should keep a stable provider reference.
35
+ */
36
+ setProvider(provider: DisabledDatesProvider | null | undefined): void;
37
+
38
+ /**
39
+ * Clears the cache and invalidates any in-flight requests.
40
+ */
41
+ reset(): void;
42
+
43
+ /**
44
+ * Whether the month containing the given date has been fully resolved.
45
+ */
46
+ isMonthLoaded(date: Date): boolean;
47
+
48
+ /**
49
+ * Whether the given date is disabled by the provider. Only returns `true` for
50
+ * dates in an already-resolved month.
51
+ */
52
+ isDateDisabled(date: Date): boolean;
53
+
54
+ /**
55
+ * Whether the given date cannot be selected yet: it is disabled by the
56
+ * provider, or its month has not been resolved. Returns `false` when no
57
+ * provider is set.
58
+ */
59
+ isDateBlocked(date: Date): boolean;
60
+
61
+ /**
62
+ * Ensures the provider has been consulted for the inclusive range between the
63
+ * given dates, expanded by a prefetch buffer of months on each side.
64
+ */
65
+ ensureRangeLoaded(startDate: Date, endDate: Date): void;
66
+ }
@@ -0,0 +1,241 @@
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 dates disabled by the date-picker's
37
+ * `disabledDatesProvider`. It calls the provider once for a range of months
38
+ * (never one date at a time), caches the resolved months so scrolling back and
39
+ * forth does not re-fetch them, and prefetches a buffer of months around the
40
+ * requested range. While any request is in flight, {@link #loading} is `true`
41
+ * so the overlay can show a spinner.
42
+ *
43
+ * The provider may return an array synchronously or a `Promise`, so results
44
+ * from a server (Flow) or a remote availability service can be awaited.
45
+ */
46
+ export class DisabledDatesController {
47
+ constructor(host, onChange) {
48
+ this.host = host;
49
+ this.__onChange = onChange;
50
+ this.provider = null;
51
+ this.__disabledDates = new Set();
52
+ this.__loadedMonths = new Set();
53
+ this.__pendingMonths = new Set();
54
+ this.__requestId = 0;
55
+ }
56
+
57
+ hostDisconnected() {
58
+ // Invalidate in-flight requests so their late results don't touch a
59
+ // detached host, and drop the pending (loading) state. The resolved cache
60
+ // is kept so reopening the overlay does not re-fetch.
61
+ this.__requestId += 1;
62
+ this.__pendingMonths = new Set();
63
+ }
64
+
65
+ /**
66
+ * Whether any month range is currently being fetched.
67
+ * @return {boolean}
68
+ */
69
+ get loading() {
70
+ return this.__pendingMonths.size > 0;
71
+ }
72
+
73
+ /**
74
+ * Sets the provider function and clears the cache. Passing the same provider
75
+ * again is a no-op, so the cache survives unrelated re-renders — callers
76
+ * should therefore keep a stable provider reference rather than passing a new
77
+ * function on every update, which would otherwise reset the cache and
78
+ * re-fetch every visible range.
79
+ */
80
+ setProvider(provider) {
81
+ if (this.provider === provider) {
82
+ return;
83
+ }
84
+ this.provider = provider;
85
+ this.reset();
86
+ }
87
+
88
+ /** Clears the cache and invalidates any in-flight requests. */
89
+ reset() {
90
+ this.__disabledDates = new Set();
91
+ this.__loadedMonths = new Set();
92
+ this.__pendingMonths = new Set();
93
+ this.__requestId += 1;
94
+ this.__notify();
95
+ }
96
+
97
+ /**
98
+ * Whether the month containing the given date has been fully resolved.
99
+ * @param {Date} date
100
+ * @return {boolean}
101
+ */
102
+ isMonthLoaded(date) {
103
+ return this.__loadedMonths.has(monthKey(date));
104
+ }
105
+
106
+ /**
107
+ * Whether the given date is disabled by the provider. Only returns `true` for
108
+ * dates in an already-resolved month.
109
+ * @param {Date} date
110
+ * @return {boolean}
111
+ */
112
+ isDateDisabled(date) {
113
+ return !!date && this.__disabledDates.has(dateKey(date));
114
+ }
115
+
116
+ /**
117
+ * Whether the given date cannot be selected yet: it is disabled by the
118
+ * provider, or its month has not been resolved so its state is still unknown.
119
+ * Returns `false` when no provider is set. Used to block selection and to
120
+ * render dates as non-selectable while their month is loading.
121
+ * @param {Date} date
122
+ * @return {boolean}
123
+ */
124
+ isDateBlocked(date) {
125
+ return !!this.provider && !!date && (this.isDateDisabled(date) || !this.isMonthLoaded(date));
126
+ }
127
+
128
+ /**
129
+ * Ensures the provider has been consulted for the inclusive range between the
130
+ * given dates, expanded by a prefetch buffer of months on each side. Months
131
+ * that are already loaded or in flight are skipped, and consecutive missing
132
+ * months are grouped into a single provider call.
133
+ *
134
+ * @param {Date} startDate
135
+ * @param {Date} endDate
136
+ */
137
+ ensureRangeLoaded(startDate, endDate) {
138
+ if (!this.provider || !startDate || !endDate) {
139
+ return;
140
+ }
141
+
142
+ const first = addMonths(firstOfMonth(startDate), -PREFETCH_MONTHS);
143
+ const last = addMonths(firstOfMonth(endDate), PREFETCH_MONTHS);
144
+
145
+ const monthsToLoad = [];
146
+ for (let month = first; month <= last; month = addMonths(month, 1)) {
147
+ const key = monthKey(month);
148
+ if (!this.__loadedMonths.has(key) && !this.__pendingMonths.has(key)) {
149
+ monthsToLoad.push(new Date(month));
150
+ }
151
+ }
152
+
153
+ if (monthsToLoad.length === 0) {
154
+ return;
155
+ }
156
+
157
+ this.__groupConsecutiveMonths(monthsToLoad).forEach((group) => this.__loadGroup(group));
158
+ }
159
+
160
+ /** @private */
161
+ __groupConsecutiveMonths(months) {
162
+ const groups = [];
163
+ months.forEach((month) => {
164
+ const group = groups.at(-1);
165
+ const previous = group?.at(-1);
166
+ if (previous && monthKey(addMonths(previous, 1)) === monthKey(month)) {
167
+ group.push(month);
168
+ } else {
169
+ groups.push([month]);
170
+ }
171
+ });
172
+ return groups;
173
+ }
174
+
175
+ /** @private */
176
+ __loadGroup(months) {
177
+ const requestId = this.__requestId;
178
+ months.forEach((month) => this.__pendingMonths.add(monthKey(month)));
179
+ this.__notify();
180
+
181
+ const range = {
182
+ start: extractDateParts(firstOfMonth(months[0])),
183
+ end: extractDateParts(lastOfMonth(months[months.length - 1])),
184
+ };
185
+
186
+ let result;
187
+ try {
188
+ result = this.provider(range);
189
+ } catch (error) {
190
+ // Treat a throwing provider the same as a rejected promise: clear the
191
+ // pending state and disable nothing, rather than letting the error
192
+ // propagate out of the scroll/render path that triggered the load.
193
+ console.error(error);
194
+ this.__resolveGroup(months, [], requestId);
195
+ return;
196
+ }
197
+
198
+ if (result && typeof result.then === 'function') {
199
+ result.then(
200
+ (dates) => this.__resolveGroup(months, dates, requestId),
201
+ (error) => {
202
+ console.error(error);
203
+ this.__resolveGroup(months, [], requestId);
204
+ },
205
+ );
206
+ } else {
207
+ this.__resolveGroup(months, result, requestId);
208
+ }
209
+ }
210
+
211
+ /** @private */
212
+ __resolveGroup(months, dates, requestId) {
213
+ // Ignore results from before the last reset (e.g. provider changed).
214
+ if (requestId !== this.__requestId) {
215
+ return;
216
+ }
217
+
218
+ if (Array.isArray(dates)) {
219
+ dates.forEach((date) => {
220
+ if (date) {
221
+ this.__disabledDates.add(`${date.year}-${date.month}-${date.day}`);
222
+ }
223
+ });
224
+ }
225
+
226
+ months.forEach((month) => {
227
+ const key = monthKey(month);
228
+ this.__pendingMonths.delete(key);
229
+ this.__loadedMonths.add(key);
230
+ });
231
+
232
+ this.__notify();
233
+ }
234
+
235
+ /** @private */
236
+ __notify() {
237
+ if (this.__onChange) {
238
+ this.__onChange();
239
+ }
240
+ }
241
+ }
@@ -87,6 +87,28 @@ export const MonthCalendarMixin = (superClass) =>
87
87
  value: () => false,
88
88
  },
89
89
 
90
+ /**
91
+ * The shared controller that resolves and caches the dates disabled by the date-picker's
92
+ * `disabledDatesProvider`. Set by the overlay content; used to look up the disabled state
93
+ * of this month's dates.
94
+ * @type {object | undefined}
95
+ * @private
96
+ */
97
+ disabledDatesController: {
98
+ type: Object,
99
+ attribute: false,
100
+ },
101
+
102
+ /**
103
+ * Bumped by the overlay content whenever the controller's cache or loading state changes,
104
+ * to trigger a re-render of this month.
105
+ * @private
106
+ */
107
+ __disabledDatesVersion: {
108
+ type: Number,
109
+ attribute: false,
110
+ },
111
+
90
112
  enteredDate: {
91
113
  type: Date,
92
114
  },
@@ -100,7 +122,8 @@ export const MonthCalendarMixin = (superClass) =>
100
122
  /** @protected */
101
123
  _days: {
102
124
  type: Array,
103
- computed: '__computeDays(month, i18n, minDate, maxDate, isDateDisabled)',
125
+ computed:
126
+ '__computeDays(month, i18n, minDate, maxDate, isDateDisabled, disabledDatesController, __disabledDatesVersion)',
104
127
  },
105
128
 
106
129
  /** @protected */
@@ -345,6 +368,10 @@ export const MonthCalendarMixin = (superClass) =>
345
368
  result.push('disabled');
346
369
  }
347
370
 
371
+ if (date && this.__isMonthLoading()) {
372
+ result.push('pending');
373
+ }
374
+
348
375
  if (dateEquals(date, focusedDate) && (hasFocus || dateEquals(date, enteredDate))) {
349
376
  result.push('focused');
350
377
  }
@@ -378,9 +405,24 @@ export const MonthCalendarMixin = (superClass) =>
378
405
  return String(this.__isDaySelected(date, selectedDate));
379
406
  }
380
407
 
408
+ /**
409
+ * Whether the provider result for the currently displayed month is still pending. The overlay
410
+ * drives loading for the whole visible range; here we only reflect the controller's state.
411
+ * @private
412
+ */
413
+ __isMonthLoading() {
414
+ const controller = this.disabledDatesController;
415
+ return !!controller?.provider && this.month !== undefined && !controller.isMonthLoaded(this.month);
416
+ }
417
+
381
418
  /** @private */
382
419
  __isDayDisabled(date, minDate, maxDate, isDateDisabled) {
383
- return !dateAllowed(date, minDate, maxDate, isDateDisabled);
420
+ if (!dateAllowed(date, minDate, maxDate, isDateDisabled)) {
421
+ return true;
422
+ }
423
+ // A date is blocked while its month is pending (whole month non-selectable) or once the
424
+ // resolved provider result marks it disabled.
425
+ return !!this.disabledDatesController?.isDateBlocked(date);
384
426
  }
385
427
 
386
428
  /** @private */