diff --git a/dev/date-picker.html b/dev/date-picker.html index efb7df19080..1e5782e4344 100644 --- a/dev/date-picker.html +++ b/dev/date-picker.html @@ -29,5 +29,46 @@ picker.isDateDisabled = isDateDisabled; picker.min = '2023-11-01'; + +

Date metadata provider

+

+ Metadata for each date is loaded on demand (a spinner is shown while loading) via + dateMetadataProvider. Mondays and the 20th of each month are disabled; the 10th and 25th + are enabled but tagged with a custom part name (busy) and styled orange. Results are + cached and prefetched around the visible months, so scrolling back and forth does not re-fetch. +

+ + + + + + + diff --git a/packages/date-picker/src/styles/vaadin-date-picker-overlay-content-base-styles.js b/packages/date-picker/src/styles/vaadin-date-picker-overlay-content-base-styles.js index d3f80d52e1f..2ae27559472 100644 --- a/packages/date-picker/src/styles/vaadin-date-picker-overlay-content-base-styles.js +++ b/packages/date-picker/src/styles/vaadin-date-picker-overlay-content-base-styles.js @@ -17,6 +17,7 @@ export const overlayContentStyles = css` height: 100%; outline: none; overflow: hidden; + position: relative; } :host([desktop]) { @@ -49,6 +50,17 @@ export const overlayContentStyles = css` display: none !important; } + /* Loading spinner shown while data is being loaded (currently while the disabled dates provider + resolves). Positioned over the month scroller, which paints its own transformed layers, so + grid stacking is not enough. */ + [part='loader'] { + position: absolute; + z-index: 1; + inset-block-start: var(--vaadin-date-picker-month-header-font-size, 0.9375rem); + inset-inline: 0; + margin-inline: auto; + } + ::slotted([slot='months']) { --vaadin-infinite-scroller-item-height: round( var(--vaadin-date-picker-month-header-font-size, 0.9375rem) + 0.75rem + diff --git a/packages/date-picker/src/vaadin-date-metadata-controller.d.ts b/packages/date-picker/src/vaadin-date-metadata-controller.d.ts new file mode 100644 index 00000000000..3c05b26f2da --- /dev/null +++ b/packages/date-picker/src/vaadin-date-metadata-controller.d.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright (c) 2016 - 2026 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { DatePickerDateMetadata, DatePickerDateRange } from './vaadin-date-picker-mixin.js'; + +export type DateMetadataProvider = ( + range: DatePickerDateRange, +) => DatePickerDateMetadata[] | Promise; + +/** + * A reactive controller that resolves the metadata (disabled state, custom part + * names, ...) for the dates shown by the date-picker's `dateMetadataProvider`. + * It calls the provider once for a range of months, caches the resolved months + * so scrolling back and forth does not re-fetch, and prefetches a buffer of + * months around the requested range. + */ +export declare class DateMetadataController implements ReactiveController { + constructor(host: ReactiveControllerHost, onChange: () => void); + + /** + * The provider function, or `null` when none is set. + */ + provider: DateMetadataProvider | null; + + /** + * Whether any month range is currently being fetched. + */ + readonly loading: boolean; + + hostDisconnected(): void; + + /** + * Sets the provider function and clears the cache. Passing the same provider + * again is a no-op. Callers should keep a stable provider reference. + */ + setProvider(provider: DateMetadataProvider | null | undefined): void; + + /** + * Clears the cache and invalidates any in-flight requests. + */ + reset(): void; + + /** + * Whether the month containing the given date has been fully resolved. + */ + isMonthLoaded(date: Date): boolean; + + /** + * The metadata resolved for the given date, or `undefined` when the date has + * no metadata or its month has not been resolved yet. + */ + getMetadata(date: Date): DatePickerDateMetadata | undefined; + + /** + * Whether the given date is disabled by the provider. Only returns `true` for + * dates in an already-resolved month. + */ + isDateDisabled(date: Date): boolean; + + /** + * Whether the given date cannot be selected yet: it is disabled by the + * provider, or its month has not been resolved. Returns `false` when no + * provider is set. + */ + isDateBlocked(date: Date): boolean; + + /** + * Ensures the provider has been consulted for the inclusive range between the + * given dates, expanded by a prefetch buffer of months on each side. + */ + ensureRangeLoaded(startDate: Date, endDate: Date): void; +} diff --git a/packages/date-picker/src/vaadin-date-metadata-controller.js b/packages/date-picker/src/vaadin-date-metadata-controller.js new file mode 100644 index 00000000000..444ffa79cd0 --- /dev/null +++ b/packages/date-picker/src/vaadin-date-metadata-controller.js @@ -0,0 +1,254 @@ +/** + * @license + * Copyright (c) 2016 - 2026 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { extractDateParts } from './vaadin-date-picker-helper.js'; + +/** + * Number of months fetched before and after the requested range, so that + * scrolling a few months in either direction does not trigger a new request. + * Mirrors how the grid prefetches rows around the viewport. + */ +const PREFETCH_MONTHS = 6; + +function monthKey(date) { + return `${date.getFullYear()}-${date.getMonth()}`; +} + +function dateKey(date) { + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +function firstOfMonth(date) { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function lastOfMonth(date) { + return new Date(date.getFullYear(), date.getMonth() + 1, 0); +} + +function addMonths(date, months) { + return new Date(date.getFullYear(), date.getMonth() + months, 1); +} + +/** + * A reactive controller that resolves the metadata (disabled state, custom part + * names, ...) for the dates shown by the date-picker's `dateMetadataProvider`. + * It calls the provider once for a range of months (never one date at a time), + * caches the resolved months so scrolling back and forth does not re-fetch them, + * and prefetches a buffer of months around the requested range. While any + * request is in flight, {@link #loading} is `true` so the overlay can show a + * spinner. + * + * The provider may return an array synchronously or a `Promise`, so results + * from a server (Flow) or a remote availability service can be awaited. Each + * returned entry is a `DatePickerDate` extended with metadata fields, e.g. + * `{ year, month, day, disabled: true, part: 'busy' }`. + */ +export class DateMetadataController { + constructor(host, onChange) { + this.host = host; + this.__onChange = onChange; + this.provider = null; + this.__metadata = new Map(); + this.__loadedMonths = new Set(); + this.__pendingMonths = new Set(); + this.__requestId = 0; + } + + hostDisconnected() { + // Invalidate in-flight requests so their late results don't touch a + // detached host, and drop the pending (loading) state. The resolved cache + // is kept so reopening the overlay does not re-fetch. + this.__requestId += 1; + this.__pendingMonths = new Set(); + } + + /** + * Whether any month range is currently being fetched. + * @return {boolean} + */ + get loading() { + return this.__pendingMonths.size > 0; + } + + /** + * Sets the provider function and clears the cache. Passing the same provider + * again is a no-op, so the cache survives unrelated re-renders — callers + * should therefore keep a stable provider reference rather than passing a new + * function on every update, which would otherwise reset the cache and + * re-fetch every visible range. + */ + setProvider(provider) { + if (this.provider === provider) { + return; + } + this.provider = provider; + this.reset(); + } + + /** Clears the cache and invalidates any in-flight requests. */ + reset() { + this.__metadata = new Map(); + this.__loadedMonths = new Set(); + this.__pendingMonths = new Set(); + this.__requestId += 1; + this.__notify(); + } + + /** + * Whether the month containing the given date has been fully resolved. + * @param {Date} date + * @return {boolean} + */ + isMonthLoaded(date) { + return this.__loadedMonths.has(monthKey(date)); + } + + /** + * The metadata resolved for the given date, or `undefined` when the date has + * no metadata or its month has not been resolved yet. + * @param {Date} date + * @return {object | undefined} + */ + getMetadata(date) { + return date ? this.__metadata.get(dateKey(date)) : undefined; + } + + /** + * Whether the given date is disabled by the provider. Only returns `true` for + * dates in an already-resolved month. + * @param {Date} date + * @return {boolean} + */ + isDateDisabled(date) { + return !!this.getMetadata(date)?.disabled; + } + + /** + * Whether the given date cannot be selected yet: it is disabled by the + * provider, or its month has not been resolved so its state is still unknown. + * Returns `false` when no provider is set. Used to block selection and to + * render dates as non-selectable while their month is loading. + * @param {Date} date + * @return {boolean} + */ + isDateBlocked(date) { + return !!this.provider && !!date && (this.isDateDisabled(date) || !this.isMonthLoaded(date)); + } + + /** + * Ensures the provider has been consulted for the inclusive range between the + * given dates, expanded by a prefetch buffer of months on each side. Months + * that are already loaded or in flight are skipped, and consecutive missing + * months are grouped into a single provider call. + * + * @param {Date} startDate + * @param {Date} endDate + */ + ensureRangeLoaded(startDate, endDate) { + if (!this.provider || !startDate || !endDate) { + return; + } + + const first = addMonths(firstOfMonth(startDate), -PREFETCH_MONTHS); + const last = addMonths(firstOfMonth(endDate), PREFETCH_MONTHS); + + const monthsToLoad = []; + for (let month = first; month <= last; month = addMonths(month, 1)) { + const key = monthKey(month); + if (!this.__loadedMonths.has(key) && !this.__pendingMonths.has(key)) { + monthsToLoad.push(new Date(month)); + } + } + + if (monthsToLoad.length === 0) { + return; + } + + this.__groupConsecutiveMonths(monthsToLoad).forEach((group) => this.__loadGroup(group)); + } + + /** @private */ + __groupConsecutiveMonths(months) { + const groups = []; + months.forEach((month) => { + const group = groups.at(-1); + const previous = group?.at(-1); + if (previous && monthKey(addMonths(previous, 1)) === monthKey(month)) { + group.push(month); + } else { + groups.push([month]); + } + }); + return groups; + } + + /** @private */ + __loadGroup(months) { + const requestId = this.__requestId; + months.forEach((month) => this.__pendingMonths.add(monthKey(month))); + this.__notify(); + + const range = { + start: extractDateParts(firstOfMonth(months[0])), + end: extractDateParts(lastOfMonth(months[months.length - 1])), + }; + + let result; + try { + result = this.provider(range); + } catch (error) { + // Treat a throwing provider the same as a rejected promise: clear the + // pending state and disable nothing, rather than letting the error + // propagate out of the scroll/render path that triggered the load. + console.error(error); + this.__resolveGroup(months, [], requestId); + return; + } + + if (result && typeof result.then === 'function') { + result.then( + (dates) => this.__resolveGroup(months, dates, requestId), + (error) => { + console.error(error); + this.__resolveGroup(months, [], requestId); + }, + ); + } else { + this.__resolveGroup(months, result, requestId); + } + } + + /** @private */ + __resolveGroup(months, entries, requestId) { + // Ignore results from before the last reset (e.g. provider changed). + if (requestId !== this.__requestId) { + return; + } + + if (Array.isArray(entries)) { + entries.forEach((entry) => { + if (entry) { + this.__metadata.set(`${entry.year}-${entry.month}-${entry.day}`, entry); + } + }); + } + + months.forEach((month) => { + const key = monthKey(month); + this.__pendingMonths.delete(key); + this.__loadedMonths.add(key); + }); + + this.__notify(); + } + + /** @private */ + __notify() { + if (this.__onChange) { + this.__onChange(); + } + } +} diff --git a/packages/date-picker/src/vaadin-date-picker-mixin.d.ts b/packages/date-picker/src/vaadin-date-picker-mixin.d.ts index 3f7acb7cf37..ec04d9babfc 100644 --- a/packages/date-picker/src/vaadin-date-picker-mixin.d.ts +++ b/packages/date-picker/src/vaadin-date-picker-mixin.d.ts @@ -18,6 +18,34 @@ export interface DatePickerDate { year: number; } +export interface DatePickerDateRange { + /** + * The first date of the range (inclusive). + */ + start: DatePickerDate; + /** + * The last date of the range (inclusive). + */ + end: DatePickerDate; +} + +/** + * Metadata resolved on demand for a single date by `dateMetadataProvider`. Extends + * `DatePickerDate` with the date's metadata. Extra fields can be added and used by the + * synchronous generators (e.g. `isDateDisabled`) as context. + */ +export interface DatePickerDateMetadata extends DatePickerDate { + /** + * Whether the date cannot be selected. + */ + disabled?: boolean; + /** + * Custom part name(s) added to the date cell's `part` attribute, so a theme can style the + * date via `::part()`. Either a single name or several separated by spaces. + */ + part?: string; +} + export interface DatePickerI18n { /** * An array with the full names of months starting @@ -236,6 +264,31 @@ export declare class DatePickerMixinClass { */ isDateDisabled: (date: DatePickerDate) => boolean; + /** + * A batch function that fetches metadata for a range of dates the calendar is about to render. + * It receives a `DatePickerDateRange` object and returns, or resolves with, an array of + * `DatePickerDateMetadata` objects (a `DatePickerDate` extended with metadata such as + * `disabled` and custom `part` names) for the dates that have metadata within that range. + * + * Unlike `isDateDisabled`, which is called once per date, this function is called for a range + * of dates at a time, and again as the calendar renders further dates. The size of the range + * is decided by the calendar and may span multiple months. It may return a `Promise`, in which + * case the affected dates render in a non-selectable pending state until it resolves. + * + * `disabled` from the metadata is combined with `min`, `max` and `isDateDisabled`: a date is + * disabled if it is out of range, or `isDateDisabled` returns `true`, or its metadata marks it + * disabled. `part` names are added to the date cell so a theme can style it via `::part()`. + * + * Both `disabled` and `part` are returned by this one function, rather than by separate + * generators, so a single backend query (for example in Flow) can answer both at once instead + * of being split into two passes over the same data. + * + * Keep a stable reference to the function. Assigning a new function resets the internal cache + * and re-fetches every visible range. To reload after the underlying data changes while keeping + * the same function, call `clearCache()`. + */ + dateMetadataProvider: (range: DatePickerDateRange) => DatePickerDateMetadata[] | Promise; + /** * Opens the dropdown. */ @@ -245,4 +298,11 @@ export declare class DatePickerMixinClass { * Closes the dropdown. */ close(): void; + + /** + * Clears the cached date metadata and reloads it from `dateMetadataProvider`. Call this when the + * data behind the provider has changed (for example a date became booked) so the calendar + * reflects it, without having to replace the provider function. + */ + clearCache(): void; } diff --git a/packages/date-picker/src/vaadin-date-picker-mixin.js b/packages/date-picker/src/vaadin-date-picker-mixin.js index cec39372b61..579974383e9 100644 --- a/packages/date-picker/src/vaadin-date-picker-mixin.js +++ b/packages/date-picker/src/vaadin-date-picker-mixin.js @@ -12,6 +12,7 @@ import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js'; import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js'; import { InputConstraintsMixin } from '@vaadin/field-base/src/input-constraints-mixin.js'; import { VirtualKeyboardController } from '@vaadin/field-base/src/virtual-keyboard-controller.js'; +import { DateMetadataController } from './vaadin-date-metadata-controller.js'; import { dateAllowed, dateEquals, @@ -208,6 +209,39 @@ export const DatePickerMixin = (subclass) => type: Function, }, + /** + * A batch function that fetches metadata for a range of dates the calendar is about to + * render. It receives a `DatePickerDateRange` object (`{ start, end }` of `DatePickerDate`) + * and returns, or resolves with, an array of metadata objects — a `DatePickerDate` extended + * with metadata fields such as `disabled` and custom `part` names, e.g. + * `{ year, month, day, disabled: true, part: 'busy' }` — for the dates that have metadata + * within that range. + * + * Unlike `isDateDisabled`, which is called once per date, this function is called for a + * range of dates at a time, and again as the calendar renders further dates. The size of + * the range is decided by the calendar and may span multiple months. It may return a + * `Promise`, in which case the affected dates render in a non-selectable pending state + * until it resolves. + * + * `disabled` from the metadata is combined with `min`, `max` and `isDateDisabled`: a date + * is disabled if it is out of the min/max range, or `isDateDisabled` returns `true`, or its + * metadata marks it disabled. `part` names are added to the date cell's `part` attribute so + * a theme can style specific dates via `::part()`. + * + * Both `disabled` and `part` are returned by this one function, rather than by separate + * generators, so a single backend query (for example in Flow) can answer both at once + * instead of being split into two passes over the same data. + * + * Keep a stable reference to the function. Assigning a new function resets the internal + * cache and re-fetches every visible range. To reload after the underlying data changes + * while keeping the same function, call `clearCache()`. + * + * @type {function(DatePickerDateRange): Array | Promise> | undefined} + */ + dateMetadataProvider: { + type: Function, + }, + /** * The earliest date that can be selected. All earlier dates will be disabled. * @type {Date | undefined} @@ -261,7 +295,8 @@ export const DatePickerMixin = (subclass) => return [ '_selectedDateChanged(_selectedDate, __effectiveI18n)', '_focusedDateChanged(_focusedDate, __effectiveI18n)', - '__updateOverlayContent(_overlayContent, __effectiveI18n, label, _minDate, _maxDate, _focusedDate, _selectedDate, showWeekNumbers, isDateDisabled, __enteredDate)', + '__updateOverlayContent(_overlayContent, __effectiveI18n, label, _minDate, _maxDate, _focusedDate, _selectedDate, showWeekNumbers, isDateDisabled, dateMetadataProvider, __enteredDate)', + '__dateMetadataProviderChanged(dateMetadataProvider)', '__updateOverlayContentTheme(_overlayContent, _theme)', '__updateOverlayContentFullScreen(_overlayContent, _fullscreen)', ]; @@ -437,6 +472,13 @@ export const DatePickerMixin = (subclass) => this.addController(new VirtualKeyboardController(this)); + // Owns the cache of dates resolved by `dateMetadataProvider`. It lives on the date-picker + // rather than the overlay content so validation works even when the overlay is never opened. + // The open overlay reads the same controller to render months and show the loading spinner. + this._dateMetadataController = new DateMetadataController(this, () => this.__onDateMetadataChanged()); + this.addController(this._dateMetadataController); + this._dateMetadataController.setProvider(this.dateMetadataProvider); + this._overlayElement = this.$.overlay; } @@ -486,6 +528,26 @@ export const DatePickerMixin = (subclass) => this.$.overlay.close(); } + /** + * Clears the cached date metadata and reloads it from `dateMetadataProvider`. Call this when + * the data behind the provider has changed (for example a date became booked) so the calendar + * reflects it, without having to replace the provider function. + * + * The visible range is reloaded when the overlay is open, and the selected value is + * re-validated once its month resolves. + */ + clearCache() { + const controller = this._dateMetadataController; + if (!controller) { + return; + } + // Drops the cache and notifies, which re-renders the open overlay in the pending state and + // reloads its visible range (via __updateCalendars). Also reload the selected month so a + // value can be re-validated even while the overlay is closed. + controller.reset(); + this.__ensureSelectedDateLoaded(); + } + /** @private */ __ensureContent() { if (this._overlayContent) { @@ -578,7 +640,9 @@ export const DatePickerMixin = (subclass) => const inputValue = this._inputElementValue; const inputValid = !inputValue || (!!this._selectedDate && inputValue === this.__formatDate(this._selectedDate)); const isDateValid = - !this._selectedDate || dateAllowed(this._selectedDate, this._minDate, this._maxDate, this.isDateDisabled); + !this._selectedDate || + (dateAllowed(this._selectedDate, this._minDate, this._maxDate, this.isDateDisabled) && + !this.__isDateDisabledByProvider(this._selectedDate)); let inputValidity = true; if (this.inputElement && this.inputElement.checkValidity) { @@ -588,6 +652,93 @@ export const DatePickerMixin = (subclass) => return inputValid && isDateValid && inputValidity; } + /** + * Returns true if the given date is known to be disabled by `dateMetadataProvider`. The + * result comes from the controller's cache of already-loaded ranges. The month containing a + * selected date is loaded on demand (see `_selectedDateChanged`), so a value typed while the + * overlay is closed is re-validated once the provider answers (see `__onDateMetadataChanged`). + * Until then the date is treated as allowed, matching the overlay's rendering. + * @private + */ + __isDateDisabledByProvider(date) { + const controller = this._dateMetadataController; + return !!controller && !!controller.provider && controller.isDateDisabled(date); + } + + /** @private */ + __dateMetadataProviderChanged(dateMetadataProvider) { + // The controller is created in `ready()`; `setProvider` is called there for the initial value. + if (this._dateMetadataController) { + this._dateMetadataController.setProvider(dateMetadataProvider); + this.__ensureSelectedDateLoaded(); + } + } + + /** + * Asks the controller to resolve the month containing the selected date, so that a value set + * or typed while the overlay is closed can be validated against the provider without opening + * the overlay. When the month resolves, `__onDateMetadataChanged` re-runs validation. + * @private + */ + __ensureSelectedDateLoaded() { + const controller = this._dateMetadataController; + if (controller?.provider && this._selectedDate && !controller.isMonthLoaded(this._selectedDate)) { + this.__awaitingProviderValidation = true; + controller.ensureRangeLoaded(this._selectedDate, this._selectedDate); + } + } + + /** @private */ + __onDateMetadataChanged() { + const controller = this._dateMetadataController; + // Push the new loading and cache state to the open overlay so it re-renders the months and + // updates the spinner. When the overlay is closed there is nothing to update. + if (this._overlayContent) { + this._overlayContent.loading = controller.loading; + this._overlayContent._dateMetadataVersion += 1; + } + // Re-validate once the month containing the selected value has resolved, so a value typed + // while the picker was closed (autoOpenDisabled) is rejected as soon as the provider answers. + if (this.__awaitingProviderValidation && this._selectedDate && controller.isMonthLoaded(this._selectedDate)) { + this.__awaitingProviderValidation = false; + this._requestValidation(); + } + + this.__adjustInitialFocusForProvider(); + } + + /** + * Moves the overlay's initial focus off a date the provider turns out to disable, once the + * provider has answered for that month. Only touches the auto-picked initial date and only + * while the user has not navigated away, so disabled dates the user focuses on purpose (which + * stay keyboard-focusable) are left alone. + * @private + */ + __adjustInitialFocusForProvider() { + const content = this._overlayContent; + const controller = this._dateMetadataController; + const initial = this.__initialFocusDate; + if (!content || !initial || !controller.provider) { + return; + } + // The user has moved focus; stop trying to adjust the initial date. + if (!dateEquals(content.focusedDate, initial)) { + this.__initialFocusDate = null; + return; + } + // Wait until the provider has answered for the initial month. + if (!controller.isMonthLoaded(initial)) { + return; + } + this.__initialFocusDate = null; + if (controller.isDateDisabled(initial)) { + const closest = content.__closestSelectableDate(initial); + if (closest) { + content.focusDate(closest); + } + } + } + /** * Override method inherited from `FocusMixin` * to not call `_setFocused(true)` when focus @@ -771,6 +922,10 @@ export const DatePickerMixin = (subclass) => this._applyInputValue(selectedDate); } + // Preload the provider's answer for the selected month so the value can be validated even + // when the overlay is never opened. + this.__ensureSelectedDateLoaded(); + this.value = this._formatISO(selectedDate); this._ignoreFocusedDateChange = true; this._focusedDate = selectedDate; @@ -842,9 +997,13 @@ export const DatePickerMixin = (subclass) => selectedDate, showWeekNumbers, isDateDisabled, + dateMetadataProvider, enteredDate, ) { if (overlayContent) { + // Share the date-picker's controller so the overlay renders from the same cache that + // validation uses. Assigned before the other properties, which trigger `__updateCalendars`. + overlayContent._dateMetadataController = this._dateMetadataController; overlayContent.i18n = effectiveI18n; overlayContent.label = label; overlayContent.minDate = minDate; @@ -853,6 +1012,7 @@ export const DatePickerMixin = (subclass) => overlayContent.selectedDate = selectedDate; overlayContent.showWeekNumbers = showWeekNumbers; overlayContent.isDateDisabled = isDateDisabled; + overlayContent.dateMetadataProvider = dateMetadataProvider; overlayContent.enteredDate = enteredDate; } } @@ -901,6 +1061,12 @@ export const DatePickerMixin = (subclass) => content.focusedDate = scrollFocusDate; this._ignoreFocusedDateChange = false; + // When opening without a selected value, remember the auto-picked initial date so it can be + // moved off a provider-disabled date once the provider answers (see __onDateMetadataChanged). + // A date the user selected themselves is left in place even if the provider disables it. + this.__initialFocusDate = this._selectedDate ? null : scrollFocusDate; + this.__adjustInitialFocusForProvider(); + window.addEventListener('scroll', this._boundOnScroll, true); if (this._focusOverlayOnOpen) { @@ -964,6 +1130,8 @@ export const DatePickerMixin = (subclass) => /** @protected */ _onOverlayClosed() { + this.__initialFocusDate = null; + // Reset `aria-hidden` state. if (this.__showOthers) { this.__showOthers(); diff --git a/packages/date-picker/src/vaadin-date-picker-overlay-content-mixin.js b/packages/date-picker/src/vaadin-date-picker-overlay-content-mixin.js index 188218d36ad..cbfd5499f88 100644 --- a/packages/date-picker/src/vaadin-date-picker-overlay-content-mixin.js +++ b/packages/date-picker/src/vaadin-date-picker-overlay-content-mixin.js @@ -106,6 +106,38 @@ export const DatePickerOverlayContentMixin = (superClass) => type: Function, }, + /** + * A batch function that fetches metadata (disabled state, custom part names, ...) for a + * range of dates the calendar is about to render. Returns, or resolves with, an array of + * `DatePickerDateMetadata` objects. + * + * @type {function(DatePickerDateRange): Array | Promise> | undefined} + */ + dateMetadataProvider: { + type: Function, + }, + + /** + * Reflected while data is being loaded, so the overlay can show a loading spinner. + * Currently set while the date metadata provider is resolving. + */ + loading: { + type: Boolean, + value: false, + reflectToAttribute: true, + }, + + /** + * Bumped whenever the disabled dates controller's cache or loading state changes, to + * re-run `__updateCalendars` and push the new state to the rendered months. + * @private + */ + _dateMetadataVersion: { + type: Number, + value: 0, + attribute: false, + }, + enteredDate: { type: Date, sync: true, @@ -138,9 +170,10 @@ export const DatePickerOverlayContentMixin = (superClass) => static get observers() { return [ - '__updateCalendars(calendars, i18n, minDate, maxDate, selectedDate, focusedDate, showWeekNumbers, _ignoreTaps, _theme, isDateDisabled, enteredDate)', + '__updateCalendars(calendars, i18n, minDate, maxDate, selectedDate, focusedDate, showWeekNumbers, _ignoreTaps, _theme, isDateDisabled, _dateMetadataVersion, enteredDate)', + '__loadingChanged(loading)', '__updateCancelButton(_cancelButton, i18n)', - '__updateTodayButton(_todayButton, i18n, minDate, maxDate, isDateDisabled)', + '__updateTodayButton(_todayButton, i18n, minDate, maxDate, isDateDisabled, _dateMetadataVersion)', '__updateYears(years, selectedDate, _theme)', ]; } @@ -299,6 +332,17 @@ export const DatePickerOverlayContentMixin = (superClass) => } } + /** @private */ + __loadingChanged(loading) { + // Mark the calendar region as busy while data is being loaded so assistive technology knows + // the content is still updating. + if (loading) { + this.setAttribute('aria-busy', 'true'); + } else { + this.removeAttribute('aria-busy'); + } + } + // eslint-disable-next-line @typescript-eslint/max-params __updateCalendars( calendars, @@ -311,6 +355,7 @@ export const DatePickerOverlayContentMixin = (superClass) => ignoreTaps, theme, isDateDisabled, + dateMetadataVersion, enteredDate, ) { if (calendars?.length) { @@ -319,6 +364,8 @@ export const DatePickerOverlayContentMixin = (superClass) => calendar.minDate = minDate; calendar.maxDate = maxDate; calendar.isDateDisabled = isDateDisabled; + calendar.dateMetadataController = this._dateMetadataController; + calendar.__dateMetadataVersion = dateMetadataVersion; calendar.focusedDate = focusedDate; calendar.selectedDate = selectedDate; calendar.showWeekNumbers = showWeekNumbers; @@ -331,7 +378,46 @@ export const DatePickerOverlayContentMixin = (superClass) => calendar.removeAttribute('theme'); } }); + + this.__ensureVisibleDateMetadataLoaded(); + } + } + + /** + * Asks the disabled dates controller to load the range of months currently rendered by the + * scroller. The controller expands the range with a prefetch buffer and skips months that are + * already loaded or in flight, so one request covers the whole visible range. + * @private + */ + __ensureVisibleDateMetadataLoaded() { + const controller = this._dateMetadataController; + if (!controller?.provider || !this.calendars || this.calendars.length === 0) { + return; } + const indexes = this.calendars + .map((calendar) => calendar.month) + .filter(Boolean) + .map((month) => month.getFullYear() * 12 + month.getMonth()); + if (indexes.length === 0) { + return; + } + const min = Math.min(...indexes); + const max = Math.max(...indexes); + controller.ensureRangeLoaded( + new Date(Math.floor(min / 12), min % 12, 1), + new Date(Math.floor(max / 12), max % 12, 1), + ); + } + + /** + * Debounced variant used on scroll, so a continuous scroll only triggers one load once it + * settles instead of a request per intermediate position. + * @private + */ + __scheduleVisibleDateMetadataLoad() { + this._loadDateMetadataDebouncer = Debouncer.debounce(this._loadDateMetadataDebouncer, timeOut.after(200), () => + this.__ensureVisibleDateMetadataLoaded(), + ); } /** @private */ @@ -357,6 +443,11 @@ export const DatePickerOverlayContentMixin = (superClass) => if (!this._dateAllowed(dateToSelect)) { return false; } + // Block dates disabled by the provider, or in a month whose provider result has not loaded + // yet, so they cannot be selected via keyboard or the today button either. + if (this._dateMetadataController?.isDateBlocked(dateToSelect)) { + return false; + } this.selectedDate = dateToSelect; this.dispatchEvent( new CustomEvent('date-selected', { detail: { date: dateToSelect }, bubbles: true, composed: true }), @@ -452,12 +543,17 @@ export const DatePickerOverlayContentMixin = (superClass) => const monthPosition = this._monthScroller.position; this._visibleMonthIndex = Math.floor(monthPosition); this._yearScroller.position = (monthPosition + this._originDate.getMonth()) / 12; + // Called from every navigation path (month scroll, year click, reveal), so this is the + // single place to keep the disabled dates loaded for the newly visible months. Debounced + // so a continuous scroll only triggers one load once it settles. + this.__scheduleVisibleDateMetadataLoad(); } /** @private */ _repositionMonthScroller() { this._monthScroller.position = this._yearScroller.position * 12 - this._originDate.getMonth(); this._visibleMonthIndex = Math.floor(this._monthScroller.position); + this.__scheduleVisibleDateMetadataLoad(); } /** @private */ @@ -831,6 +927,33 @@ export const DatePickerOverlayContentMixin = (superClass) => this.focusDate(getClosestDate(focus, [this.minDate, this.maxDate])); } + /** + * Returns the nearest date to the given one that can actually be selected: inside min/max, not + * disabled by `isDateDisabled`, and not disabled by a resolved `dateMetadataProvider`. Scans a + * year in both directions and returns `undefined` if none is found, so the caller can leave the + * focus untouched. + * @private + */ + __closestSelectableDate(date) { + const controller = this._dateMetadataController; + const isSelectable = (candidate) => + this._dateAllowed(candidate) && !(controller?.provider && controller.isDateDisabled(candidate)); + + if (isSelectable(date)) { + return date; + } + for (let offset = 1; offset <= 366; offset++) { + for (const direction of [1, -1]) { + const candidate = new Date(date); + candidate.setDate(candidate.getDate() + offset * direction); + if (isSelectable(candidate)) { + return candidate; + } + } + } + return undefined; + } + /** @private */ _focusAllowedDate(dateToFocus, diff, keepMonth) { // For this check we do consider the isDateDisabled function because disabled dates are allowed to be focused, just not outside min/max @@ -918,7 +1041,12 @@ export const DatePickerOverlayContentMixin = (superClass) => /** @private */ _isTodayAllowed(min, max, isDateDisabled) { - return this._dateAllowed(this._getTodayMidnight(), min, max, isDateDisabled); + const today = this._getTodayMidnight(); + if (!this._dateAllowed(today, min, max, isDateDisabled)) { + return false; + } + // Today is not selectable while it is disabled by the provider or its month is still loading. + return !this._dateMetadataController?.isDateBlocked(today); } /** @private */ diff --git a/packages/date-picker/src/vaadin-date-picker-overlay-content.js b/packages/date-picker/src/vaadin-date-picker-overlay-content.js index 4bad2596c5c..536636bf57d 100644 --- a/packages/date-picker/src/vaadin-date-picker-overlay-content.js +++ b/packages/date-picker/src/vaadin-date-picker-overlay-content.js @@ -12,6 +12,7 @@ import { html, LitElement } from 'lit'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { DirMixin } from '@vaadin/component-base/src/dir-mixin.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; +import { loaderStyles } from '@vaadin/component-base/src/styles/loader-styles.js'; import { LumoInjectionMixin } from '@vaadin/vaadin-themable-mixin/lumo-injection-mixin.js'; import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; import { overlayContentStyles } from './styles/vaadin-date-picker-overlay-content-base-styles.js'; @@ -30,7 +31,7 @@ class DatePickerOverlayContent extends DatePickerOverlayContentMixin( } static get styles() { - return overlayContentStyles; + return [loaderStyles, overlayContentStyles]; } static get lumoInjector() { @@ -43,6 +44,8 @@ class DatePickerOverlayContent extends DatePickerOverlayContentMixin( + +
)`. + * * In order to style year scroller elements, use `` shadow DOM parts: * * Part name | Description diff --git a/packages/date-picker/src/vaadin-date-picker.js b/packages/date-picker/src/vaadin-date-picker.js index e3b4ca08d53..358a480c4ab 100644 --- a/packages/date-picker/src/vaadin-date-picker.js +++ b/packages/date-picker/src/vaadin-date-picker.js @@ -92,6 +92,7 @@ import { DatePickerMixin } from './vaadin-date-picker-mixin.js'; * ----------------------|-------------------- * `years-toggle-button` | Fullscreen mode years scroller toggle * `toolbar` | Toolbar with slotted buttons + * `loader` | Loading spinner shown while data is being loaded, for example while the disabled dates provider resolves * * The following state attributes are available on the `` element: * @@ -100,6 +101,7 @@ import { DatePickerMixin } from './vaadin-date-picker-mixin.js'; * `desktop` | Set when the overlay content is in desktop mode * `fullscreen` | Set when the overlay content is in fullscreen mode * `years-visible` | Set when the year scroller is visible in fullscreen mode + * `loading` | Set while data is being loaded, for example while the disabled dates provider resolves * * In order to style the month calendar, use `` shadow DOM parts: * @@ -112,12 +114,17 @@ import { DatePickerMixin } from './vaadin-date-picker-mixin.js'; * `week-number` | Week number element * `date` | Date element * `disabled` | Disabled date element + * `pending` | Date element in a month whose date metadata provider result is still loading * `focused` | Focused date element * `selected` | Selected date element * `today` | Date element corresponding to the current day * `past` | Date element corresponding to the date in the past * `future` | Date element corresponding to the date in the future * + * Custom part names returned per date by `dateMetadataProvider` (the `part` metadata field) are + * also added to the matching date elements, so specific dates can be styled with + * `vaadin-month-calendar::part()`. + * * In order to style year scroller elements, use `` shadow DOM parts: * * Part name | Description diff --git a/packages/date-picker/src/vaadin-month-calendar-mixin.js b/packages/date-picker/src/vaadin-month-calendar-mixin.js index 9d65a8ff28a..ecc15c722e8 100644 --- a/packages/date-picker/src/vaadin-month-calendar-mixin.js +++ b/packages/date-picker/src/vaadin-month-calendar-mixin.js @@ -87,6 +87,28 @@ export const MonthCalendarMixin = (superClass) => value: () => false, }, + /** + * The shared controller that resolves and caches the dates disabled by the date-picker's + * `dateMetadataProvider`. Set by the overlay content; used to look up the disabled state + * of this month's dates. + * @type {object | undefined} + * @private + */ + dateMetadataController: { + type: Object, + attribute: false, + }, + + /** + * Bumped by the overlay content whenever the controller's cache or loading state changes, + * to trigger a re-render of this month. + * @private + */ + __dateMetadataVersion: { + type: Number, + attribute: false, + }, + enteredDate: { type: Date, }, @@ -100,7 +122,8 @@ export const MonthCalendarMixin = (superClass) => /** @protected */ _days: { type: Array, - computed: '__computeDays(month, i18n, minDate, maxDate, isDateDisabled)', + computed: + '__computeDays(month, i18n, minDate, maxDate, isDateDisabled, dateMetadataController, __dateMetadataVersion)', }, /** @protected */ @@ -345,6 +368,10 @@ export const MonthCalendarMixin = (superClass) => result.push('disabled'); } + if (date && this.__isMonthLoading()) { + result.push('pending'); + } + if (dateEquals(date, focusedDate) && (hasFocus || dateEquals(date, enteredDate))) { result.push('focused'); } @@ -365,6 +392,13 @@ export const MonthCalendarMixin = (superClass) => result.push('future'); } + // Custom part names supplied per date by `dateMetadataProvider`, so a theme can style + // specific dates (e.g. mark a date as busy or almost fully booked) via `::part()`. + const part = date && this.dateMetadataController?.getMetadata(date)?.part; + if (part) { + result.push(...(Array.isArray(part) ? part : String(part).split(' ')).filter(Boolean)); + } + return result.join(' '); } @@ -378,9 +412,24 @@ export const MonthCalendarMixin = (superClass) => return String(this.__isDaySelected(date, selectedDate)); } + /** + * Whether the provider result for the currently displayed month is still pending. The overlay + * drives loading for the whole visible range; here we only reflect the controller's state. + * @private + */ + __isMonthLoading() { + const controller = this.dateMetadataController; + return !!controller?.provider && this.month !== undefined && !controller.isMonthLoaded(this.month); + } + /** @private */ __isDayDisabled(date, minDate, maxDate, isDateDisabled) { - return !dateAllowed(date, minDate, maxDate, isDateDisabled); + if (!dateAllowed(date, minDate, maxDate, isDateDisabled)) { + return true; + } + // A date is blocked while its month is pending (whole month non-selectable) or once the + // resolved provider result marks it disabled. + return !!this.dateMetadataController?.isDateBlocked(date); } /** @private */ diff --git a/packages/date-picker/test/date-metadata-controller.test.js b/packages/date-picker/test/date-metadata-controller.test.js new file mode 100644 index 00000000000..054f4a3b610 --- /dev/null +++ b/packages/date-picker/test/date-metadata-controller.test.js @@ -0,0 +1,160 @@ +import { expect } from '@vaadin/chai-plugins'; +import { aTimeout } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; +import { DateMetadataController } from '../src/vaadin-date-metadata-controller.js'; + +describe('DateMetadataController', () => { + let controller, onChange; + + beforeEach(() => { + onChange = sinon.spy(); + controller = new DateMetadataController({}, onChange); + }); + + it('should call the provider once for a range covering the prefetch window', () => { + const provider = sinon.stub().returns([]); + controller.setProvider(provider); + + controller.ensureRangeLoaded(new Date(2023, 2, 1), new Date(2023, 2, 1)); // March 2023 + + expect(provider).to.be.calledOnce; + // March 2023 ± 6 months = Sept 2022 (month 8) through Sept 2023. + expect(provider.firstCall.args[0]).to.eql({ + start: { year: 2022, month: 8, day: 1 }, + end: { year: 2023, month: 8, day: 30 }, + }); + }); + + it('should mark the requested months as loaded and expose disabled dates', () => { + controller.setProvider(() => [{ year: 2023, month: 2, day: 15, disabled: true }]); + controller.ensureRangeLoaded(new Date(2023, 2, 1), new Date(2023, 2, 1)); + + expect(controller.isMonthLoaded(new Date(2023, 2, 1))).to.be.true; + expect(controller.isDateDisabled(new Date(2023, 2, 15))).to.be.true; + expect(controller.isDateDisabled(new Date(2023, 2, 16))).to.be.false; + expect(controller.loading).to.be.false; + }); + + it('should expose arbitrary metadata for a date', () => { + controller.setProvider(() => [ + { year: 2023, month: 2, day: 10, part: 'busy' }, + { year: 2023, month: 2, day: 15, disabled: true }, + ]); + controller.ensureRangeLoaded(new Date(2023, 2, 1), new Date(2023, 2, 1)); + + expect(controller.getMetadata(new Date(2023, 2, 10))).to.include({ part: 'busy' }); + expect(controller.getMetadata(new Date(2023, 2, 15))).to.include({ disabled: true }); + // A date with metadata but no `disabled` flag is not disabled. + expect(controller.isDateDisabled(new Date(2023, 2, 10))).to.be.false; + // A date without any metadata returns undefined. + expect(controller.getMetadata(new Date(2023, 2, 11))).to.be.undefined; + }); + + it('should not call the provider again for months within the loaded window (scrolling back and forth)', () => { + const provider = sinon.stub().returns([]); + controller.setProvider(provider); + + // Load a wide span first (Jan..Dec 2023 → window Jul 2022..Jun 2024). + controller.ensureRangeLoaded(new Date(2023, 0, 1), new Date(2023, 11, 1)); + provider.resetHistory(); + + // Re-request months whose full prefetch window is already covered. + controller.ensureRangeLoaded(new Date(2023, 0, 1), new Date(2023, 11, 1)); + controller.ensureRangeLoaded(new Date(2023, 5, 1), new Date(2023, 5, 1)); // June, window Dec22..Dec23 + + expect(provider).to.not.be.called; + }); + + it('should fetch only the new months when scrolling one month forward', () => { + const provider = sinon.stub().returns([]); + controller.setProvider(provider); + + controller.ensureRangeLoaded(new Date(2023, 2, 1), new Date(2023, 2, 1)); // window Sep22..Sep23 + provider.resetHistory(); + + controller.ensureRangeLoaded(new Date(2023, 3, 1), new Date(2023, 3, 1)); // window Oct22..Oct23 + + // Only the single new trailing month (Oct 2023) should be fetched. + expect(provider).to.be.calledOnce; + expect(provider.firstCall.args[0]).to.eql({ + start: { year: 2023, month: 9, day: 1 }, + end: { year: 2023, month: 9, day: 31 }, + }); + }); + + it('should fetch again for a range far outside the loaded window', () => { + const provider = sinon.stub().returns([]); + controller.setProvider(provider); + + controller.ensureRangeLoaded(new Date(2023, 2, 1), new Date(2023, 2, 1)); + provider.resetHistory(); + + controller.ensureRangeLoaded(new Date(2025, 2, 1), new Date(2025, 2, 1)); + expect(provider).to.be.calledOnce; + }); + + describe('async provider', () => { + let resolveProvider, provider; + + beforeEach(() => { + provider = sinon.stub().returns( + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + controller.setProvider(provider); + }); + + it('should be loading until the promise resolves', async () => { + controller.ensureRangeLoaded(new Date(2023, 2, 1), new Date(2023, 2, 1)); + expect(controller.loading).to.be.true; + expect(controller.isMonthLoaded(new Date(2023, 2, 1))).to.be.false; + + resolveProvider([{ year: 2023, month: 2, day: 15, disabled: true }]); + await aTimeout(0); + + expect(controller.loading).to.be.false; + expect(controller.isMonthLoaded(new Date(2023, 2, 1))).to.be.true; + expect(controller.isDateDisabled(new Date(2023, 2, 15))).to.be.true; + }); + + it('should notify the host when loading starts and finishes', async () => { + onChange.resetHistory(); + controller.ensureRangeLoaded(new Date(2023, 2, 1), new Date(2023, 2, 1)); + expect(onChange).to.be.called; // request started + + onChange.resetHistory(); + resolveProvider([]); + await aTimeout(0); + expect(onChange).to.be.called; // request finished + }); + }); + + it('should clear the cache when the provider changes', () => { + controller.setProvider(() => [{ year: 2023, month: 2, day: 15, disabled: true }]); + controller.ensureRangeLoaded(new Date(2023, 2, 1), new Date(2023, 2, 1)); + expect(controller.isDateDisabled(new Date(2023, 2, 15))).to.be.true; + + controller.setProvider(() => []); + expect(controller.isMonthLoaded(new Date(2023, 2, 1))).to.be.false; + expect(controller.isDateDisabled(new Date(2023, 2, 15))).to.be.false; + }); + + it('should ignore an async result that resolves after a reset', async () => { + let resolveStale; + controller.setProvider( + () => + new Promise((resolve) => { + resolveStale = resolve; + }), + ); + controller.ensureRangeLoaded(new Date(2023, 2, 1), new Date(2023, 2, 1)); + + // Provider changes (reset) before the first request resolves. + controller.setProvider(() => []); + resolveStale([{ year: 2023, month: 2, day: 15, disabled: true }]); + await aTimeout(0); + + expect(controller.isDateDisabled(new Date(2023, 2, 15))).to.be.false; + }); +}); diff --git a/packages/date-picker/test/date-metadata-provider.test.js b/packages/date-picker/test/date-metadata-provider.test.js new file mode 100644 index 00000000000..982123f48f4 --- /dev/null +++ b/packages/date-picker/test/date-metadata-provider.test.js @@ -0,0 +1,392 @@ +import { expect } from '@vaadin/chai-plugins'; +import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; +import '../src/vaadin-date-picker.js'; +import { open } from './helpers.js'; + +describe('dateMetadataProvider integration', () => { + let datePicker, today, year, month; + + function getMonthCalendar(y, m) { + return [...datePicker._overlayContent.querySelectorAll('vaadin-month-calendar')].find( + (calendar) => calendar.month && calendar.month.getFullYear() === y && calendar.month.getMonth() === m, + ); + } + + function getCell(calendar, day) { + return [...calendar.shadowRoot.querySelectorAll('[part~="date"]:not(:empty)')].find( + (cell) => cell.date.getDate() === day, + ); + } + + function isDisabled(cell) { + return cell.hasAttribute('disabled') && cell.part.contains('disabled'); + } + + function hasPart(cell, part) { + return cell.part.contains(part); + } + + // Waits (bounded) until the predicate holds, to absorb the multi-step reactive chain that runs + // after a provider resolves (resolve -> version bump -> calendars re-render), which can span + // more than one render on slower browsers. + async function untilRendered(predicate) { + for (let i = 0; i < 50 && !predicate(); i++) { + await nextRender(); + } + } + + // A provider that disables the 15th of every month in the requested range. + function disableFifteenth({ start, end }) { + const disabled = []; + const first = new Date(start.year, start.month, start.day); + const last = new Date(end.year, end.month, end.day); + const days = Math.round((last - first) / (24 * 60 * 60 * 1000)); + for (let i = 0; i <= days; i++) { + const date = new Date(first.getFullYear(), first.getMonth(), first.getDate() + i); + if (date.getDate() === 15) { + disabled.push({ year: date.getFullYear(), month: date.getMonth(), day: date.getDate(), disabled: true }); + } + } + return disabled; + } + + // A provider that tags the 10th of every month with a custom `busy` part name. + function busyTenth({ start, end }) { + const metadata = []; + const first = new Date(start.year, start.month, start.day); + const last = new Date(end.year, end.month, end.day); + const days = Math.round((last - first) / (24 * 60 * 60 * 1000)); + for (let i = 0; i <= days; i++) { + const date = new Date(first.getFullYear(), first.getMonth(), first.getDate() + i); + if (date.getDate() === 10) { + metadata.push({ year: date.getFullYear(), month: date.getMonth(), day: date.getDate(), part: 'busy' }); + } + } + return metadata; + } + + beforeEach(() => { + datePicker = fixtureSync(''); + today = new Date(); + year = today.getFullYear(); + month = today.getMonth(); + }); + + describe('synchronous provider', () => { + beforeEach(async () => { + datePicker.dateMetadataProvider = disableFifteenth; + await open(datePicker); + }); + + it('should mark only the provided dates as disabled', () => { + const calendar = getMonthCalendar(year, month); + expect(isDisabled(getCell(calendar, 15))).to.be.true; + expect(isDisabled(getCell(calendar, 16))).to.be.false; + }); + + it('should not show the loading spinner for a synchronous result', () => { + expect(datePicker._overlayContent.hasAttribute('loading')).to.be.false; + }); + }); + + describe('asynchronous provider', () => { + let provider, resolveProvider; + + beforeEach(async () => { + provider = sinon.stub().callsFake( + () => + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + datePicker.dateMetadataProvider = provider; + await open(datePicker); + }); + + it('should consult the provider for a range wider than a single month', () => { + const { start, end } = provider.firstCall.args[0]; + const months = (end.year - start.year) * 12 + (end.month - start.month); + expect(months).to.be.greaterThan(1); + }); + + it('should show the loading spinner and mark the calendar busy while pending', () => { + expect(datePicker._overlayContent.hasAttribute('loading')).to.be.true; + expect(datePicker._overlayContent.getAttribute('aria-busy')).to.equal('true'); + const loader = datePicker._overlayContent.shadowRoot.querySelector('[part="loader"]'); + expect(loader).to.exist; + expect(getComputedStyle(loader).display).to.not.equal('none'); + }); + + it('should hide the spinner and mark the provided dates disabled after resolving', async () => { + resolveProvider(disableFifteenth(provider.firstCall.args[0])); + // The 16th is disabled (pending) while loading and becomes enabled once resolved. + await untilRendered(() => { + const calendar = getMonthCalendar(year, month); + const cell = calendar && getCell(calendar, 16); + return cell && !isDisabled(cell); + }); + + expect(datePicker._overlayContent.hasAttribute('loading')).to.be.false; + expect(datePicker._overlayContent.hasAttribute('aria-busy')).to.be.false; + const calendar = getMonthCalendar(year, month); + expect(isDisabled(getCell(calendar, 15))).to.be.true; + expect(isDisabled(getCell(calendar, 16))).to.be.false; + }); + + it('should not re-consult the provider for already loaded months', async () => { + resolveProvider([]); + await nextRender(); + provider.resetHistory(); + + // Re-trigger a load for the same visible months (scroll loading is debounced). + datePicker._overlayContent._onMonthScroll(); + datePicker._overlayContent._loadDateMetadataDebouncer?.flush(); + await nextRender(); + + expect(provider).to.not.be.called; + }); + + it('should load disabled dates when navigating to another month (e.g. via the year scroller)', async () => { + resolveProvider(disableFifteenth(provider.firstCall.args[0])); + await nextRender(); + provider.resetHistory(); + + // Navigate far ahead without scrolling — the same code path a year click uses. + const target = new Date(year + 3, month, 1); + datePicker._overlayContent.scrollToDate(target, false); + datePicker._overlayContent._loadDateMetadataDebouncer?.flush(); + await nextRender(); + + expect(provider).to.be.called; + const { start, end } = provider.lastCall.args[0]; + const targetIndex = target.getFullYear() * 12 + target.getMonth(); + expect(start.year * 12 + start.month).to.be.at.most(targetIndex); + expect(end.year * 12 + end.month).to.be.at.least(targetIndex); + + resolveProvider(disableFifteenth(provider.lastCall.args[0])); + // Wait until the target month resolves (its 16th, disabled while pending, becomes enabled). + await untilRendered(() => { + const calendar = getMonthCalendar(target.getFullYear(), target.getMonth()); + const cell = calendar && getCell(calendar, 16); + return cell && !isDisabled(cell); + }); + + const calendar = getMonthCalendar(target.getFullYear(), target.getMonth()); + expect(isDisabled(getCell(calendar, 15))).to.be.true; + }); + }); + + // The overlay is never opened here: validation must consult the provider on its own, otherwise a + // value typed with auto-open disabled, or set programmatically, would be accepted even though the + // provider disables it. + describe('validation without opening the overlay', () => { + it('should invalidate a disabled value with a synchronous provider', () => { + datePicker.dateMetadataProvider = disableFifteenth; + datePicker.value = '2020-01-15'; + expect(datePicker._overlayContent).to.be.not.ok; + expect(datePicker.validate()).to.be.false; + expect(datePicker.invalid).to.be.true; + }); + + it('should keep an enabled value valid with a synchronous provider', () => { + datePicker.dateMetadataProvider = disableFifteenth; + datePicker.value = '2020-01-16'; + expect(datePicker.validate()).to.be.true; + expect(datePicker.invalid).to.be.false; + }); + + it('should invalidate a disabled value once an asynchronous provider resolves', async () => { + let resolveProvider; + const provider = sinon.stub().callsFake( + () => + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + datePicker.dateMetadataProvider = provider; + datePicker.value = '2020-01-15'; + + // The provider has not answered yet, so the value is treated as valid for now. + expect(datePicker.validate()).to.be.true; + expect(provider).to.be.called; + + resolveProvider(disableFifteenth(provider.firstCall.args[0])); + await untilRendered(() => datePicker.invalid); + + expect(datePicker._overlayContent).to.be.not.ok; + expect(datePicker.invalid).to.be.true; + expect(datePicker.checkValidity()).to.be.false; + }); + }); + + describe('initial focus', () => { + beforeEach(() => { + datePicker.dateMetadataProvider = disableFifteenth; + }); + + it('should move initial focus off a provider-disabled date once the provider resolves', async () => { + datePicker.initialPosition = '2020-01-15'; + await open(datePicker); + const content = datePicker._overlayContent; + + await untilRendered(() => content.focusedDate && content.focusedDate.getDate() !== 15); + + // The 15th is disabled, so focus moves to the closest selectable date, the 16th. + expect(content.focusedDate.getFullYear()).to.equal(2020); + expect(content.focusedDate.getMonth()).to.equal(0); + expect(content.focusedDate.getDate()).to.equal(16); + }); + + it('should keep initial focus on an enabled initial position', async () => { + datePicker.initialPosition = '2020-01-10'; + await open(datePicker); + const content = datePicker._overlayContent; + + await untilRendered(() => content._dateMetadataController.isMonthLoaded(new Date(2020, 0, 10))); + + expect(content.focusedDate.getDate()).to.equal(10); + }); + + it('should not move focus the user has navigated to a disabled date', async () => { + let resolveProvider; + const provider = sinon.stub().callsFake( + () => + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + datePicker.dateMetadataProvider = provider; + datePicker.initialPosition = '2020-01-10'; + await open(datePicker); + const content = datePicker._overlayContent; + + // User navigates to the 15th (disabled but focusable) before the provider resolves. + content.focusedDate = new Date(2020, 0, 15); + resolveProvider(disableFifteenth(provider.firstCall.args[0])); + await untilRendered(() => content._dateMetadataController.isMonthLoaded(new Date(2020, 0, 15))); + + // Focus stays on the user's chosen date; disabled dates remain keyboard-focusable. + expect(content.focusedDate.getDate()).to.equal(15); + }); + }); + + describe('custom part names', () => { + it('should add the metadata part name to the matching date cells', async () => { + datePicker.dateMetadataProvider = busyTenth; + await open(datePicker); + + const calendar = getMonthCalendar(year, month); + expect(hasPart(getCell(calendar, 10), 'busy')).to.be.true; + expect(hasPart(getCell(calendar, 11), 'busy')).to.be.false; + }); + + it('should not disable a date that only carries a part name', async () => { + datePicker.dateMetadataProvider = busyTenth; + await open(datePicker); + + const calendar = getMonthCalendar(year, month); + expect(isDisabled(getCell(calendar, 10))).to.be.false; + }); + + it('should apply both disabled and part metadata on the same date', async () => { + datePicker.dateMetadataProvider = () => [{ year, month, day: 12, disabled: true, part: 'busy' }]; + await open(datePicker); + + const cell = getCell(getMonthCalendar(year, month), 12); + expect(isDisabled(cell)).to.be.true; + expect(hasPart(cell, 'busy')).to.be.true; + }); + + it('should add the part name after an async provider resolves', async () => { + let resolveProvider; + const provider = sinon.stub().callsFake( + () => + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + datePicker.dateMetadataProvider = provider; + await open(datePicker); + + // While pending, the part is not there yet. + expect(hasPart(getCell(getMonthCalendar(year, month), 10), 'busy')).to.be.false; + + resolveProvider(busyTenth(provider.firstCall.args[0])); + await untilRendered(() => hasPart(getCell(getMonthCalendar(year, month), 10), 'busy')); + + expect(hasPart(getCell(getMonthCalendar(year, month), 10), 'busy')).to.be.true; + }); + }); + + describe('clearCache', () => { + // A provider that disables the day currently held in `disabledDay`, so the "server data" can + // be changed between calls. + let disabledDay; + function disableDay({ start, end }) { + const list = []; + const first = new Date(start.year, start.month, start.day); + const last = new Date(end.year, end.month, end.day); + const days = Math.round((last - first) / (24 * 60 * 60 * 1000)); + for (let i = 0; i <= days; i++) { + const date = new Date(first.getFullYear(), first.getMonth(), first.getDate() + i); + if (date.getDate() === disabledDay) { + list.push({ year: date.getFullYear(), month: date.getMonth(), day: date.getDate(), disabled: true }); + } + } + return list; + } + + it('should re-fetch the visible range and reflect changed metadata', async () => { + disabledDay = 15; + datePicker.dateMetadataProvider = disableDay; + await open(datePicker); + expect(isDisabled(getCell(getMonthCalendar(year, month), 15))).to.be.true; + expect(isDisabled(getCell(getMonthCalendar(year, month), 16))).to.be.false; + + // The availability changes on the server: the 16th is now disabled instead of the 15th. + disabledDay = 16; + datePicker.clearCache(); + await untilRendered(() => { + const cell = getCell(getMonthCalendar(year, month), 16); + return cell && isDisabled(cell); + }); + + expect(isDisabled(getCell(getMonthCalendar(year, month), 16))).to.be.true; + expect(isDisabled(getCell(getMonthCalendar(year, month), 15))).to.be.false; + }); + + it('should re-consult the provider (cache is dropped)', async () => { + disabledDay = 15; + const provider = sinon.spy(disableDay); + datePicker.dateMetadataProvider = provider; + await open(datePicker); + provider.resetHistory(); + + datePicker.clearCache(); + await nextRender(); + + expect(provider).to.be.called; + }); + + it('should re-validate the selected value against refreshed metadata without opening the overlay', () => { + let disabled = []; + datePicker.dateMetadataProvider = () => disabled; + datePicker.value = '2020-01-15'; + expect(datePicker.validate()).to.be.true; + expect(datePicker.invalid).to.be.false; + + // The 15th becomes booked; refresh the cache. + disabled = [{ year: 2020, month: 0, day: 15, disabled: true }]; + datePicker.clearCache(); + + expect(datePicker._overlayContent).to.be.not.ok; + expect(datePicker.invalid).to.be.true; + expect(datePicker.checkValidity()).to.be.false; + }); + + it('should not throw when no provider is set', () => { + expect(() => datePicker.clearCache()).to.not.throw(); + }); + }); +}); diff --git a/packages/date-picker/test/not-animated-styles.css b/packages/date-picker/test/not-animated-styles.css index 2f5df350821..ed3cdafaa71 100644 --- a/packages/date-picker/test/not-animated-styles.css +++ b/packages/date-picker/test/not-animated-styles.css @@ -27,3 +27,8 @@ vaadin-date-picker input:placeholder-shown { vaadin-month-calendar::part(focused)::before { animation: none !important; } + +/* Show the loading spinner immediately instead of fading it in */ +vaadin-date-picker-overlay-content::part(loader) { + animation: none !important; +} diff --git a/packages/date-picker/test/typings/date-picker.types.ts b/packages/date-picker/test/typings/date-picker.types.ts index 52b5a5fd723..24961db656d 100644 --- a/packages/date-picker/test/typings/date-picker.types.ts +++ b/packages/date-picker/test/typings/date-picker.types.ts @@ -11,7 +11,13 @@ import type { InputMixinClass } from '@vaadin/field-base/src/input-mixin.js'; import type { LabelMixinClass } from '@vaadin/field-base/src/label-mixin.js'; import type { ValidateMixinClass } from '@vaadin/field-base/src/validate-mixin.js'; import type { ThemableMixinClass } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; -import type { DatePickerDate, DatePickerI18n, DatePickerMixinClass } from '../../src/vaadin-date-picker-mixin.js'; +import type { + DatePickerDate, + DatePickerDateMetadata, + DatePickerDateRange, + DatePickerI18n, + DatePickerMixinClass, +} from '../../src/vaadin-date-picker-mixin.js'; import type { DatePicker, DatePickerChangeEvent, @@ -57,9 +63,23 @@ assertType<() => boolean>(datePicker.checkValidity); assertType<() => boolean>(datePicker.validate); assertType<() => void>(datePicker.close); assertType<() => void>(datePicker.open); +assertType<() => void>(datePicker.clearCache); assertType(datePicker.max); assertType(datePicker.min); assertType<(date: DatePickerDate) => boolean | undefined>(datePicker.isDateDisabled); +assertType<(range: DatePickerDateRange) => DatePickerDateMetadata[] | Promise>( + datePicker.dateMetadataProvider, +); +const dateRange: DatePickerDateRange = { + start: { year: 2024, month: 0, day: 1 }, + end: { year: 2024, month: 0, day: 31 }, +}; +assertType(dateRange.start); +assertType(dateRange.end); +const dateMetadata: DatePickerDateMetadata = { year: 2024, month: 0, day: 1, disabled: true, part: 'busy' }; +assertType(dateMetadata.year); +assertType(dateMetadata.disabled); +assertType(dateMetadata.part); assertType(datePicker.showWeekNumbers); assertType(datePicker.autoOpenDisabled); assertType(datePicker.opened); diff --git a/packages/date-picker/test/visual/aura/date-picker.test.js b/packages/date-picker/test/visual/aura/date-picker.test.js index f10fa77b72f..77e9ce796ff 100644 --- a/packages/date-picker/test/visual/aura/date-picker.test.js +++ b/packages/date-picker/test/visual/aura/date-picker.test.js @@ -3,6 +3,7 @@ import { visualDiff } from '@web/test-runner-visual-regression'; import '@vaadin/aura/aura.css'; import '../../not-animated-styles.css'; import '../../../vaadin-date-picker.js'; +import { untilOverlayRendered } from '../../helpers.js'; describe('date-picker', () => { let div, element; @@ -63,6 +64,14 @@ describe('date-picker', () => { await visualDiff(div, 'disabled-date'); }); + it('disabled dates loading', async () => { + // A never-resolving provider keeps the calendar in the loading state. + element.dateMetadataProvider = () => new Promise(() => {}); + openOverlay(); + await untilOverlayRendered(element); + await visualDiff(div, 'disabled-dates-loading'); + }); + it('week numbers', async () => { element.showWeekNumbers = true; element.i18n = { firstDayOfWeek: 1 }; diff --git a/packages/date-picker/test/visual/aura/screenshots/dark/date-picker/baseline/disabled-dates-loading.png b/packages/date-picker/test/visual/aura/screenshots/dark/date-picker/baseline/disabled-dates-loading.png new file mode 100644 index 00000000000..1cc23922168 Binary files /dev/null and b/packages/date-picker/test/visual/aura/screenshots/dark/date-picker/baseline/disabled-dates-loading.png differ diff --git a/packages/date-picker/test/visual/aura/screenshots/default/date-picker/baseline/disabled-dates-loading.png b/packages/date-picker/test/visual/aura/screenshots/default/date-picker/baseline/disabled-dates-loading.png new file mode 100644 index 00000000000..684dc64944c Binary files /dev/null and b/packages/date-picker/test/visual/aura/screenshots/default/date-picker/baseline/disabled-dates-loading.png differ diff --git a/packages/date-picker/test/visual/base/date-picker.test.js b/packages/date-picker/test/visual/base/date-picker.test.js index b71dd8fa75c..45dc6c3730c 100644 --- a/packages/date-picker/test/visual/base/date-picker.test.js +++ b/packages/date-picker/test/visual/base/date-picker.test.js @@ -3,6 +3,7 @@ import { fixtureSync, mousedown } from '@vaadin/testing-helpers'; import { visualDiff } from '@web/test-runner-visual-regression'; import '../../not-animated-styles.css'; import '../../../src/vaadin-date-picker.js'; +import { untilOverlayRendered } from '../../helpers.js'; describe('date-picker', () => { let div, element; @@ -93,6 +94,15 @@ describe('date-picker', () => { await visualDiff(div, `${dir}-dropdown`); }); + it('disabled dates loading', async () => { + element.value = '2000-01-01'; + // A never-resolving provider keeps the calendar in the loading state. + element.dateMetadataProvider = () => new Promise(() => {}); + openOverlay(); + await untilOverlayRendered(element); + await visualDiff(div, `${dir}-disabled-dates-loading`); + }); + it('week numbers', async () => { element.value = '2000-01-01'; element.showWeekNumbers = true; diff --git a/packages/date-picker/test/visual/base/screenshots/date-picker/baseline/ltr-disabled-dates-loading.png b/packages/date-picker/test/visual/base/screenshots/date-picker/baseline/ltr-disabled-dates-loading.png new file mode 100644 index 00000000000..bc897e3f28c Binary files /dev/null and b/packages/date-picker/test/visual/base/screenshots/date-picker/baseline/ltr-disabled-dates-loading.png differ diff --git a/packages/date-picker/test/visual/base/screenshots/date-picker/baseline/rtl-disabled-dates-loading.png b/packages/date-picker/test/visual/base/screenshots/date-picker/baseline/rtl-disabled-dates-loading.png new file mode 100644 index 00000000000..f996fed46e8 Binary files /dev/null and b/packages/date-picker/test/visual/base/screenshots/date-picker/baseline/rtl-disabled-dates-loading.png differ diff --git a/packages/date-picker/test/visual/lumo/date-picker.test.js b/packages/date-picker/test/visual/lumo/date-picker.test.js index 1e3d109879b..7e270d5e4e4 100644 --- a/packages/date-picker/test/visual/lumo/date-picker.test.js +++ b/packages/date-picker/test/visual/lumo/date-picker.test.js @@ -116,6 +116,14 @@ describe('date-picker', () => { await visualDiff(div, `${dir}-dropdown`); }); + it('disabled dates loading', async () => { + element.value = '2000-01-01'; + // A never-resolving provider keeps the calendar in the loading state. + element.dateMetadataProvider = () => new Promise(() => {}); + await openOverlay(); + await visualDiff(div, `${dir}-disabled-dates-loading`); + }); + it('week numbers', async () => { element.value = '2000-01-01'; element.showWeekNumbers = true; diff --git a/packages/date-picker/test/visual/lumo/screenshots/date-picker/baseline/ltr-disabled-dates-loading.png b/packages/date-picker/test/visual/lumo/screenshots/date-picker/baseline/ltr-disabled-dates-loading.png new file mode 100644 index 00000000000..4eb7fba68d7 Binary files /dev/null and b/packages/date-picker/test/visual/lumo/screenshots/date-picker/baseline/ltr-disabled-dates-loading.png differ diff --git a/packages/date-picker/test/visual/lumo/screenshots/date-picker/baseline/rtl-disabled-dates-loading.png b/packages/date-picker/test/visual/lumo/screenshots/date-picker/baseline/rtl-disabled-dates-loading.png new file mode 100644 index 00000000000..045f04221b7 Binary files /dev/null and b/packages/date-picker/test/visual/lumo/screenshots/date-picker/baseline/rtl-disabled-dates-loading.png differ