@vaadin/date-picker 25.3.0-alpha1 → 25.3.0-alpha11

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaadin/date-picker",
3
- "version": "25.3.0-alpha1",
3
+ "version": "25.3.0-alpha11",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -35,21 +35,21 @@
35
35
  ],
36
36
  "dependencies": {
37
37
  "@open-wc/dedupe-mixin": "^1.3.0",
38
- "@vaadin/a11y-base": "25.3.0-alpha1",
39
- "@vaadin/button": "25.3.0-alpha1",
40
- "@vaadin/component-base": "25.3.0-alpha1",
41
- "@vaadin/field-base": "25.3.0-alpha1",
42
- "@vaadin/input-container": "25.3.0-alpha1",
43
- "@vaadin/overlay": "25.3.0-alpha1",
44
- "@vaadin/vaadin-themable-mixin": "25.3.0-alpha1",
38
+ "@vaadin/a11y-base": "25.3.0-alpha11",
39
+ "@vaadin/button": "25.3.0-alpha11",
40
+ "@vaadin/component-base": "25.3.0-alpha11",
41
+ "@vaadin/field-base": "25.3.0-alpha11",
42
+ "@vaadin/input-container": "25.3.0-alpha11",
43
+ "@vaadin/overlay": "25.3.0-alpha11",
44
+ "@vaadin/vaadin-themable-mixin": "25.3.0-alpha11",
45
45
  "lit": "^3.0.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@vaadin/aura": "25.3.0-alpha1",
49
- "@vaadin/chai-plugins": "25.3.0-alpha1",
50
- "@vaadin/test-runner-commands": "25.3.0-alpha1",
48
+ "@vaadin/aura": "25.3.0-alpha11",
49
+ "@vaadin/chai-plugins": "25.3.0-alpha11",
50
+ "@vaadin/test-runner-commands": "25.3.0-alpha11",
51
51
  "@vaadin/testing-helpers": "^2.0.0",
52
- "@vaadin/vaadin-lumo-styles": "25.3.0-alpha1",
52
+ "@vaadin/vaadin-lumo-styles": "25.3.0-alpha11",
53
53
  "sinon": "^22.0.0"
54
54
  },
55
55
  "customElements": "custom-elements.json",
@@ -57,5 +57,5 @@
57
57
  "web-types.json",
58
58
  "web-types.lit.json"
59
59
  ],
60
- "gitHead": "5824d54c995945aafd50d30edc2cb763a58a18b6"
60
+ "gitHead": "7e0c61a37e68d8971def9cdf28ad0548a0f530a9"
61
61
  }
@@ -17,6 +17,7 @@ export const overlayContentStyles = css`
17
17
  height: 100%;
18
18
  outline: none;
19
19
  overflow: hidden;
20
+ position: relative;
20
21
  }
21
22
 
22
23
  :host([desktop]) {
@@ -49,6 +50,14 @@ export const overlayContentStyles = css`
49
50
  display: none !important;
50
51
  }
51
52
 
53
+ [part='loader'] {
54
+ position: absolute;
55
+ z-index: 1;
56
+ inset-block-start: var(--vaadin-date-picker-month-header-font-size, 0.9375rem);
57
+ inset-inline: 0;
58
+ margin-inline: auto;
59
+ }
60
+
52
61
  ::slotted([slot='months']) {
53
62
  --vaadin-infinite-scroller-item-height: round(
54
63
  var(--vaadin-date-picker-month-header-font-size, 0.9375rem) + 0.75rem +
@@ -0,0 +1,94 @@
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, ReactiveElement } from 'lit';
7
+ import type { DatePickerDateMetadata, DatePickerDateMetadataProvider } from './vaadin-date-picker-mixin.js';
8
+
9
+ /**
10
+ * A reactive controller that resolves the metadata (currently the disabled state)
11
+ * for the dates shown by the date-picker's `dateMetadataProvider`.
12
+ *
13
+ * The provider is called for a range of months and may return an array
14
+ * synchronously or a `Promise`, so results from a server (Flow) or a remote
15
+ * availability service can be awaited. The range and each returned entry identify
16
+ * a date by an ISO 8601 string, e.g. `{ date: '2026-01-01', disabled: true }`.
17
+ *
18
+ * `ARCHITECTURE.md` in this package records the reasoning behind the request,
19
+ * caching, notification and failure behavior.
20
+ */
21
+ export class DateMetadataController implements ReactiveController {
22
+ /**
23
+ * The controller host element.
24
+ */
25
+ host: HTMLElement & ReactiveControllerHost;
26
+
27
+ /**
28
+ * The provider function, or `null` when none is set.
29
+ */
30
+ provider: DatePickerDateMetadataProvider | null;
31
+
32
+ constructor(host: HTMLElement & ReactiveControllerHost, onChange?: () => void);
33
+
34
+ hostConnected(): void;
35
+
36
+ /**
37
+ * Registers an element to be re-rendered whenever the resolved metadata or the
38
+ * loading state changes. The element must render from its bindings, and must not be
39
+ * the one whose own observer triggers a load. It stays registered for the
40
+ * controller's lifetime.
41
+ */
42
+ subscribe(element: ReactiveElement): void;
43
+
44
+ /**
45
+ * Whether any month range is currently being fetched.
46
+ */
47
+ isLoading(): boolean;
48
+
49
+ /**
50
+ * Sets the provider function and clears the cache. Passing the same provider again
51
+ * is a no-op, so callers should keep a stable reference.
52
+ */
53
+ setProvider(provider: DatePickerDateMetadataProvider | null | undefined): void;
54
+
55
+ /**
56
+ * Clears the cache and invalidates any in-flight requests.
57
+ */
58
+ clearCache(): void;
59
+
60
+ /**
61
+ * Whether the provider has answered for the month containing the given date.
62
+ * A month whose request failed is not loaded and will be requested again.
63
+ */
64
+ isMonthLoaded(date: Date | null | undefined): boolean;
65
+
66
+ /**
67
+ * Whether the month containing the given date is currently being fetched. A month
68
+ * that has not been asked about is not pending, so this reports the same state as
69
+ * `isLoading()` does for the whole cache.
70
+ */
71
+ isMonthPending(date: Date | null | undefined): boolean;
72
+
73
+ /**
74
+ * The metadata resolved for the given date, or `undefined` when the date has
75
+ * no metadata or its month has not been resolved yet. Returns the entry the
76
+ * provider supplied, which the caller must not modify.
77
+ */
78
+ getMetadata(date: Date | null | undefined): DatePickerDateMetadata | undefined;
79
+
80
+ /**
81
+ * Whether the given date is disabled by its metadata.
82
+ */
83
+ isDateDisabled(date: Date | null | undefined): boolean;
84
+
85
+ /**
86
+ * Ensures the provider has been consulted for the inclusive range between the
87
+ * given dates, rounded out to whole blocks of months. Months already loaded or in
88
+ * flight are skipped, and the ones left over are requested with a single call.
89
+ *
90
+ * Each call that finds a missing month issues its own request, so a caller that
91
+ * loads on scroll should debounce.
92
+ */
93
+ ensureRangeLoaded(startDate: Date | null | undefined, endDate: Date | null | undefined): void;
94
+ }
@@ -0,0 +1,268 @@
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 { microTask } from '@vaadin/component-base/src/async.js';
7
+ import { Debouncer } from '@vaadin/component-base/src/debounce.js';
8
+ import { issueWarning } from '@vaadin/component-base/src/warnings.js';
9
+ import { formatISODate, lastOfMonth, monthDate, monthIndex, parseDate } from './vaadin-date-picker-helper.js';
10
+
11
+ // Counted from January of year 0, so a block is one calendar year.
12
+ const BLOCK_MONTHS = 12;
13
+
14
+ function blockStart(month) {
15
+ return Math.floor(month / BLOCK_MONTHS) * BLOCK_MONTHS;
16
+ }
17
+
18
+ const PENDING_MONTH = Object.freeze({ pending: true });
19
+
20
+ // The date of an entry, or `undefined` when its `date` is not one. Checked for being a string first,
21
+ // since coercing another type to one can throw, and an entry comes from outside.
22
+ function entryDate(entry) {
23
+ return typeof entry?.date === 'string' ? parseDate(entry.date) : undefined;
24
+ }
25
+
26
+ function groupEntriesByMonth(months, entries) {
27
+ const result = new Map(months.map((month) => [month, new Map()]));
28
+
29
+ if (Array.isArray(entries)) {
30
+ entries.forEach((entry) => {
31
+ const date = entryDate(entry);
32
+ if (date) {
33
+ result.get(monthIndex(date))?.set(date.getDate(), entry);
34
+ } else {
35
+ issueWarning('Ignored `dateMetadataProvider` entries whose `date` is not an ISO 8601 date.');
36
+ }
37
+ });
38
+ } else if (entries != null) {
39
+ issueWarning('Expected `dateMetadataProvider` to return an array of date metadata objects.');
40
+ }
41
+
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * A reactive controller that resolves the metadata (currently the disabled state)
47
+ * for the dates shown by the date-picker's `dateMetadataProvider`.
48
+ *
49
+ * The provider is called for a range of months and may return an array
50
+ * synchronously or a `Promise`, so results from a server (Flow) or a remote
51
+ * availability service can be awaited. The range and each returned entry identify
52
+ * a date by an ISO 8601 string, e.g. `{ date: '2026-01-01', disabled: true }`.
53
+ *
54
+ * `ARCHITECTURE.md` in this package records the reasoning behind the request,
55
+ * caching, notification and failure behavior.
56
+ */
57
+ export class DateMetadataController {
58
+ /**
59
+ * The controller host element.
60
+ * @type {import('lit').ReactiveControllerHost & HTMLElement}
61
+ */
62
+ host;
63
+
64
+ /**
65
+ * The provider function, or `null` when none is set.
66
+ * @type {Function | null}
67
+ */
68
+ provider = null;
69
+
70
+ /** @type {(() => void) | undefined} */
71
+ #onChange;
72
+
73
+ /**
74
+ * What is known about each month, keyed by month index: a shared marker while its request is in
75
+ * flight, or a record holding the resolved entries by day. A month is absent until it is loaded,
76
+ * and absent again if its request failed.
77
+ * @type {Map<number, { pending: boolean, entries?: Map<number, object> }>}
78
+ */
79
+ #months = new Map();
80
+
81
+ /** @type {Set<import('lit').ReactiveElement>} */
82
+ #subscribers = new Set();
83
+
84
+ #requestId = 0;
85
+
86
+ /** @type {import('@vaadin/component-base/src/debounce.js').Debouncer} */
87
+ #notifyDebouncer;
88
+
89
+ constructor(host, onChange) {
90
+ this.host = host;
91
+ this.#onChange = onChange;
92
+ }
93
+
94
+ hostConnected() {
95
+ this.#notify();
96
+ }
97
+
98
+ /**
99
+ * Registers an element to be re-rendered whenever the resolved metadata or the
100
+ * loading state changes. The element must render from its bindings, and must not be
101
+ * the one whose own observer triggers a load. It stays registered for the
102
+ * controller's lifetime.
103
+ *
104
+ * @param {import('lit').ReactiveElement} element
105
+ */
106
+ subscribe(element) {
107
+ this.#subscribers.add(element);
108
+ }
109
+
110
+ /**
111
+ * Whether any month range is currently being fetched.
112
+ * @return {boolean}
113
+ */
114
+ isLoading() {
115
+ for (const { pending } of this.#months.values()) {
116
+ if (pending) {
117
+ return true;
118
+ }
119
+ }
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Sets the provider function and clears the cache. Passing the same provider again
125
+ * is a no-op, so callers should keep a stable reference.
126
+ *
127
+ * @param {Function | null | undefined} provider
128
+ */
129
+ setProvider(provider) {
130
+ const next = provider ?? null;
131
+ if (this.provider === next) {
132
+ return;
133
+ }
134
+ this.provider = next;
135
+ this.clearCache();
136
+ }
137
+
138
+ /**
139
+ * Clears the cache and invalidates any in-flight requests.
140
+ */
141
+ clearCache() {
142
+ this.#months.clear();
143
+ this.#requestId += 1;
144
+ this.#notify();
145
+ }
146
+
147
+ /**
148
+ * Whether the provider has answered for the month containing the given date.
149
+ * A month whose request failed is not loaded and will be requested again.
150
+ * @param {Date | null | undefined} date
151
+ * @return {boolean}
152
+ */
153
+ isMonthLoaded(date) {
154
+ return !!this.#resolvedMonth(date);
155
+ }
156
+
157
+ /**
158
+ * Whether the month containing the given date is currently being fetched. A month
159
+ * that has not been asked about is not pending, so this reports the same state as
160
+ * `isLoading()` does for the whole cache.
161
+ * @param {Date | null | undefined} date
162
+ * @return {boolean}
163
+ */
164
+ isMonthPending(date) {
165
+ return !!date && !!this.#months.get(monthIndex(date))?.pending;
166
+ }
167
+
168
+ /**
169
+ * The metadata resolved for the given date, or `undefined` when the date has
170
+ * no metadata or its month has not been resolved yet. Returns the entry the
171
+ * provider supplied, which the caller must not modify.
172
+ * @param {Date | null | undefined} date
173
+ * @return {object | undefined}
174
+ */
175
+ getMetadata(date) {
176
+ return this.#resolvedMonth(date)?.entries.get(date.getDate());
177
+ }
178
+
179
+ /**
180
+ * Whether the given date is disabled by its metadata.
181
+ * @param {Date | null | undefined} date
182
+ * @return {boolean}
183
+ */
184
+ isDateDisabled(date) {
185
+ return !!this.getMetadata(date)?.disabled;
186
+ }
187
+
188
+ /**
189
+ * Ensures the provider has been consulted for the inclusive range between the
190
+ * given dates, rounded out to whole blocks of months. Months already loaded or in
191
+ * flight are skipped, and the ones left over are requested with a single call.
192
+ *
193
+ * Each call that finds a missing month issues its own request, so a caller that
194
+ * loads on scroll should debounce.
195
+ *
196
+ * @param {Date | null | undefined} startDate
197
+ * @param {Date | null | undefined} endDate
198
+ */
199
+ ensureRangeLoaded(startDate, endDate) {
200
+ if (!this.provider || !startDate || !endDate) {
201
+ return;
202
+ }
203
+
204
+ const first = blockStart(monthIndex(startDate));
205
+ const last = blockStart(monthIndex(endDate)) + BLOCK_MONTHS - 1;
206
+
207
+ const months = [];
208
+ for (let month = first; month <= last; month++) {
209
+ if (!this.#months.has(month)) {
210
+ months.push(month);
211
+ }
212
+ }
213
+
214
+ if (months.length > 0) {
215
+ this.#loadMonths(months);
216
+ }
217
+ }
218
+
219
+ #resolvedMonth(date) {
220
+ const month = date && this.#months.get(monthIndex(date));
221
+ return month && !month.pending ? month : undefined;
222
+ }
223
+
224
+ async #loadMonths(months) {
225
+ const requestId = this.#requestId;
226
+ months.forEach((month) => this.#months.set(month, PENDING_MONTH));
227
+ this.#notify();
228
+
229
+ const range = {
230
+ start: formatISODate(monthDate(months[0])),
231
+ end: formatISODate(lastOfMonth(monthDate(months.at(-1)))),
232
+ };
233
+
234
+ let entries;
235
+ try {
236
+ const data = await this.provider(range);
237
+ entries = groupEntriesByMonth(months, data);
238
+ } catch (error) {
239
+ console.error(error);
240
+ }
241
+
242
+ if (requestId !== this.#requestId) {
243
+ return;
244
+ }
245
+
246
+ months.forEach((month) => {
247
+ if (entries) {
248
+ this.#months.set(month, { pending: false, entries: entries.get(month) });
249
+ } else {
250
+ this.#months.delete(month);
251
+ }
252
+ });
253
+
254
+ this.#notify();
255
+ }
256
+
257
+ #notify() {
258
+ this.#subscribers.forEach((element) => element.requestUpdate());
259
+
260
+ if (this.#onChange) {
261
+ this.#notifyDebouncer = Debouncer.debounce(this.#notifyDebouncer, microTask, () => {
262
+ if (this.host.isConnected) {
263
+ this.#onChange();
264
+ }
265
+ });
266
+ }
267
+ }
268
+ }
@@ -3,6 +3,7 @@
3
3
  * Copyright (c) 2016 - 2026 Vaadin Ltd.
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
+ import type { DateMetadataController } from './vaadin-date-metadata-controller.js';
6
7
  import type { DatePickerDate } from './vaadin-date-picker-mixin.js';
7
8
 
8
9
  /**
@@ -31,6 +32,21 @@ declare function dateAllowed(
31
32
  isDateDisabled: (date: DatePickerDate) => boolean | null,
32
33
  ): boolean;
33
34
 
35
+ /**
36
+ * Check if the given date can be selected: allowed by `dateAllowed` and not reported as disabled
37
+ * by the date metadata controller. This is narrower than `dateAllowed`, which decides what can be
38
+ * focused: a disabled date is still focusable, it just cannot be selected.
39
+ *
40
+ * @returns True if the date can be selected
41
+ */
42
+ declare function dateSelectable(
43
+ date: Date,
44
+ min: Date | null,
45
+ max: Date | null,
46
+ isDateDisabled: (date: DatePickerDate) => boolean | null,
47
+ controller?: DateMetadataController | null,
48
+ ): boolean;
49
+
34
50
  /**
35
51
  * Get closest date from array of dates.
36
52
  *
@@ -66,6 +82,7 @@ export {
66
82
  getISOWeekNumber,
67
83
  dateEquals,
68
84
  dateAllowed,
85
+ dateSelectable,
69
86
  getClosestDate,
70
87
  extractDateParts,
71
88
  dateAfterXMonths,
@@ -4,6 +4,79 @@
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
6
 
7
+ /**
8
+ * Create a date at midnight in local time. Unlike `new Date(year, month, day)`,
9
+ * this supports years below 100, which the constructor maps into the 20th
10
+ * century. The month is assigned before the day so that the initial day of month
11
+ * (1) always exists in the target month.
12
+ *
13
+ * @param {number} year
14
+ * @param {number} month Zero-based month, may be out of range to shift the year
15
+ * @param {number} day May be `0` to select the last day of the previous month
16
+ * @return {Date}
17
+ */
18
+ export function createDate(year, month, day) {
19
+ const date = new Date(0, 0); // Wrong date (1900-01-01), but with midnight in local time
20
+ date.setFullYear(year);
21
+ date.setMonth(month);
22
+ date.setDate(day);
23
+ return date;
24
+ }
25
+
26
+ /**
27
+ * Get the first day of the month the given date is in.
28
+ *
29
+ * @param {!Date} date
30
+ * @return {Date}
31
+ */
32
+ export function firstOfMonth(date) {
33
+ return createDate(date.getFullYear(), date.getMonth(), 1);
34
+ }
35
+
36
+ /**
37
+ * Get the last day of the month the given date is in.
38
+ *
39
+ * @param {!Date} date
40
+ * @return {Date}
41
+ */
42
+ export function lastOfMonth(date) {
43
+ return createDate(date.getFullYear(), date.getMonth() + 1, 0);
44
+ }
45
+
46
+ /**
47
+ * Get the index of a month, counted from January of year 0. Reduces a month to a single
48
+ * integer, so a lookup builds no key and two months are adjacent when their indexes are.
49
+ *
50
+ * @param {number} year
51
+ * @param {number} month Zero-based month
52
+ * @return {number}
53
+ */
54
+ export function monthIndexOf(year, month) {
55
+ return year * 12 + month;
56
+ }
57
+
58
+ /**
59
+ * Get the index of the month the given date is in.
60
+ *
61
+ * @param {!Date} date
62
+ * @return {number}
63
+ */
64
+ export function monthIndex(date) {
65
+ return monthIndexOf(date.getFullYear(), date.getMonth());
66
+ }
67
+
68
+ /**
69
+ * Get the first day of the month with the given index, inverting `monthIndexOf`. Counting from
70
+ * January of year 0 also inverts negative indexes, since `createDate` normalizes a month outside
71
+ * 0-11 into the year.
72
+ *
73
+ * @param {number} index
74
+ * @return {Date}
75
+ */
76
+ export function monthDate(index) {
77
+ return createDate(0, index, 1);
78
+ }
79
+
7
80
  /**
8
81
  * Get ISO 8601 week number for the given date.
9
82
  *
@@ -106,6 +179,22 @@ export function dateAllowed(date, min, max, isDateDisabled) {
106
179
  return (!min || date >= min) && (!max || date <= max) && !dateIsDisabled;
107
180
  }
108
181
 
182
+ /**
183
+ * Check if the given date can be selected: allowed by `dateAllowed` and not reported as disabled
184
+ * by the date metadata controller. This is narrower than `dateAllowed`, which decides what can be
185
+ * focused: a disabled date is still focusable, it just cannot be selected.
186
+ *
187
+ * @param {!Date} date The date to check
188
+ * @param {Date | null} min Range start
189
+ * @param {Date | null} max Range end
190
+ * @param {function(!DatePickerDate): boolean} isDateDisabled Callback to check if the date is disabled
191
+ * @param {DateMetadataController | null} [controller] The date metadata controller
192
+ * @return {boolean} True if the date can be selected
193
+ */
194
+ export function dateSelectable(date, min, max, isDateDisabled, controller) {
195
+ return dateAllowed(date, min, max, isDateDisabled) && !controller?.isDateDisabled(date);
196
+ }
197
+
109
198
  /**
110
199
  * Get closest date from array of dates.
111
200
  *
@@ -171,51 +260,66 @@ export function getAdjustedYear(referenceDate, year, month = 0, day = 1) {
171
260
  return adjustedYear;
172
261
  }
173
262
 
263
+ const ISO_DATE = /^([-+]\d{1,6}|\d{2,4})-(\d{1,2})-(\d{1,2})$/u;
264
+
265
+ // The parts of a date string in a format the parsers accept, as written.
266
+ function parseParts(str) {
267
+ // Parsing with RegExp to ensure correct format
268
+ const parts = ISO_DATE.exec(str);
269
+ if (!parts) {
270
+ return undefined;
271
+ }
272
+
273
+ return { year: parseInt(parts[1], 10), month: parseInt(parts[2], 10) - 1, day: parseInt(parts[3], 10) };
274
+ }
275
+
174
276
  /**
175
277
  * Parse date string of one of the following date formats:
176
278
  * - ISO 8601 `"YYYY-MM-DD"`
177
- * - 6-digit extended ISO 8601 `"+YYYYYY-MM-DD"`, `"-YYYYYY-MM-DD"`
279
+ * - Extended ISO 8601 with a signed year, e.g. `"+012026-MM-DD"` or `"-0001-MM-DD"`
280
+ *
281
+ * A date that does not exist, such as `"2026-02-30"`, is not parsed. Building it would carry the
282
+ * surplus into the next month or year and answer with a date that was never asked for.
283
+ *
178
284
  * @param {!string} str Date string to parse
179
285
  * @return {Date} Parsed date in system timezone
180
286
  */
181
287
  export function parseDate(str) {
182
- // Parsing with RegExp to ensure correct format
183
- const parts = /^([-+]\d{1}|\d{2,4}|[-+]\d{6})-(\d{1,2})-(\d{1,2})$/u.exec(str);
288
+ const parts = parseParts(str);
184
289
  if (!parts) {
185
290
  return undefined;
186
291
  }
187
292
 
188
- const date = new Date(0, 0); // Wrong date (1900-01-01), but with midnight in local time
189
- date.setFullYear(parseInt(parts[1], 10));
190
- date.setMonth(parseInt(parts[2], 10) - 1);
191
- date.setDate(parseInt(parts[3], 10));
192
- return date;
293
+ const date = createDate(parts.year, parts.month, parts.day);
294
+
295
+ return date.getMonth() === parts.month && date.getDate() === parts.day ? date : undefined;
193
296
  }
194
297
 
195
298
  /**
196
299
  * Parse date string of one of the following date formats:
197
300
  * - ISO 8601 `"YYYY-MM-DD"`
198
- * - 6-digit extended ISO 8601 `"+YYYYYY-MM-DD"`, `"-YYYYYY-MM-DD"`
301
+ * - Extended ISO 8601 with a signed year, e.g. `"+012026-MM-DD"` or `"-0001-MM-DD"`
199
302
  *
200
303
  * Uses UTC date components to allow handling date instances independently of
201
304
  * the system time-zone.
202
305
  *
306
+ * A date that does not exist, such as `"2026-02-30"`, is not parsed, as in `parseDate`.
307
+ *
203
308
  * @param {!string} str Date string to parse
204
309
  * @return {Date} Parsed date in UTC timezone
205
310
  */
206
311
  export function parseUTCDate(str) {
207
- // Parsing with RegExp to ensure correct format
208
- const parts = /^([-+]\d{1}|\d{2,4}|[-+]\d{6})-(\d{1,2})-(\d{1,2})$/u.exec(str);
312
+ const parts = parseParts(str);
209
313
  if (!parts) {
210
314
  return undefined;
211
315
  }
212
316
 
213
317
  const date = new Date(Date.UTC(0, 0)); // Wrong date (1900-01-01), but with midnight in UTC
214
- date.setUTCFullYear(parseInt(parts[1], 10));
215
- date.setUTCMonth(parseInt(parts[2], 10) - 1);
216
- date.setUTCDate(parseInt(parts[3], 10));
318
+ date.setUTCFullYear(parts.year);
319
+ date.setUTCMonth(parts.month);
320
+ date.setUTCDate(parts.day);
217
321
 
218
- return date;
322
+ return date.getUTCMonth() === parts.month && date.getUTCDate() === parts.day ? date : undefined;
219
323
  }
220
324
 
221
325
  function formatISODateBase(dateParts) {