@lekoala/date-picker 0.1.0

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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -0
  3. package/custom-elements.json +366 -0
  4. package/dist/date-picker.css +344 -0
  5. package/dist/date-picker.js +2693 -0
  6. package/dist/date-picker.min.css +1 -0
  7. package/dist/date-picker.min.js +17 -0
  8. package/dist/date-picker.standalone.min.js +361 -0
  9. package/dist/types/calendar-model.d.ts +34 -0
  10. package/dist/types/calendar-model.d.ts.map +1 -0
  11. package/dist/types/date-calendar.d.ts +228 -0
  12. package/dist/types/date-calendar.d.ts.map +1 -0
  13. package/dist/types/date-field.d.ts +94 -0
  14. package/dist/types/date-field.d.ts.map +1 -0
  15. package/dist/types/date-picker.d.ts +258 -0
  16. package/dist/types/date-picker.d.ts.map +1 -0
  17. package/dist/types/date-range.d.ts +89 -0
  18. package/dist/types/date-range.d.ts.map +1 -0
  19. package/dist/types/date.d.ts +61 -0
  20. package/dist/types/date.d.ts.map +1 -0
  21. package/dist/types/define.d.ts +2 -0
  22. package/dist/types/define.d.ts.map +1 -0
  23. package/dist/types/index.d.ts +9 -0
  24. package/dist/types/index.d.ts.map +1 -0
  25. package/dist/types/intl.d.ts +33 -0
  26. package/dist/types/intl.d.ts.map +1 -0
  27. package/dist/types/locales/ar.d.ts +22 -0
  28. package/dist/types/locales/ar.d.ts.map +1 -0
  29. package/dist/types/locales/de.d.ts +22 -0
  30. package/dist/types/locales/de.d.ts.map +1 -0
  31. package/dist/types/locales/en.d.ts +22 -0
  32. package/dist/types/locales/en.d.ts.map +1 -0
  33. package/dist/types/locales/es.d.ts +22 -0
  34. package/dist/types/locales/es.d.ts.map +1 -0
  35. package/dist/types/locales/fr.d.ts +22 -0
  36. package/dist/types/locales/fr.d.ts.map +1 -0
  37. package/dist/types/locales/hi.d.ts +22 -0
  38. package/dist/types/locales/hi.d.ts.map +1 -0
  39. package/dist/types/locales/id.d.ts +22 -0
  40. package/dist/types/locales/id.d.ts.map +1 -0
  41. package/dist/types/locales/it.d.ts +22 -0
  42. package/dist/types/locales/it.d.ts.map +1 -0
  43. package/dist/types/locales/ja.d.ts +22 -0
  44. package/dist/types/locales/ja.d.ts.map +1 -0
  45. package/dist/types/locales/ko.d.ts +22 -0
  46. package/dist/types/locales/ko.d.ts.map +1 -0
  47. package/dist/types/locales/nl.d.ts +22 -0
  48. package/dist/types/locales/nl.d.ts.map +1 -0
  49. package/dist/types/locales/pl.d.ts +22 -0
  50. package/dist/types/locales/pl.d.ts.map +1 -0
  51. package/dist/types/locales/pt-BR.d.ts +22 -0
  52. package/dist/types/locales/pt-BR.d.ts.map +1 -0
  53. package/dist/types/locales/pt-PT.d.ts +22 -0
  54. package/dist/types/locales/pt-PT.d.ts.map +1 -0
  55. package/dist/types/locales/ru.d.ts +22 -0
  56. package/dist/types/locales/ru.d.ts.map +1 -0
  57. package/dist/types/locales/tr.d.ts +22 -0
  58. package/dist/types/locales/tr.d.ts.map +1 -0
  59. package/dist/types/locales/zh-CN.d.ts +22 -0
  60. package/dist/types/locales/zh-CN.d.ts.map +1 -0
  61. package/dist/types/messages.d.ts +43 -0
  62. package/dist/types/messages.d.ts.map +1 -0
  63. package/dist/types/source.d.ts +28 -0
  64. package/dist/types/source.d.ts.map +1 -0
  65. package/docs/ACCESSIBILITY.md +128 -0
  66. package/docs/API.md +289 -0
  67. package/docs/ARCHITECTURE.md +183 -0
  68. package/docs/AT-CHECKLIST.md +63 -0
  69. package/docs/CALENDAR-INTEGRATION.md +70 -0
  70. package/docs/DECISIONS.md +40 -0
  71. package/docs/MIGRATION-FLATPICKR.md +92 -0
  72. package/docs/ROADMAP.md +105 -0
  73. package/docs/TESTING.md +73 -0
  74. package/docs/USE_CASES.md +128 -0
  75. package/package.json +174 -0
  76. package/src/calendar-model.js +80 -0
  77. package/src/date-calendar.js +800 -0
  78. package/src/date-field.js +203 -0
  79. package/src/date-picker.css +344 -0
  80. package/src/date-picker.js +1140 -0
  81. package/src/date-range.js +172 -0
  82. package/src/date.js +203 -0
  83. package/src/define.js +9 -0
  84. package/src/index.js +15 -0
  85. package/src/intl.js +155 -0
  86. package/src/locales/ar.js +20 -0
  87. package/src/locales/de.js +20 -0
  88. package/src/locales/en.js +20 -0
  89. package/src/locales/es.js +20 -0
  90. package/src/locales/fr.js +20 -0
  91. package/src/locales/hi.js +20 -0
  92. package/src/locales/id.js +20 -0
  93. package/src/locales/it.js +20 -0
  94. package/src/locales/ja.js +20 -0
  95. package/src/locales/ko.js +20 -0
  96. package/src/locales/nl.js +20 -0
  97. package/src/locales/pl.js +20 -0
  98. package/src/locales/pt-BR.js +20 -0
  99. package/src/locales/pt-PT.js +20 -0
  100. package/src/locales/ru.js +20 -0
  101. package/src/locales/tr.js +20 -0
  102. package/src/locales/zh-CN.js +20 -0
  103. package/src/messages.js +31 -0
  104. package/src/source.js +50 -0
@@ -0,0 +1,1140 @@
1
+ import { autoUpdate, reposition } from "@lekoala/floating";
2
+ import { compareDates, isDate, monthKey, todayISO } from "./date.js";
3
+ import { DateCalendarElement } from "./date-calendar.js";
4
+ import { DateFieldController } from "./date-field.js";
5
+ import { DateRangeController, normalizeRange } from "./date-range.js";
6
+ import { createDateAdapter, formatLongDate, resolveLocale } from "./intl.js";
7
+ import { getDefaultMessages } from "./messages.js";
8
+
9
+ let uid = 0;
10
+
11
+ export class DatePickerElement extends HTMLElement {
12
+ static observedAttributes = ["value", "locale", "min", "max", "open-on-focus"];
13
+
14
+ constructor() {
15
+ super();
16
+ this._id = `date-picker-${++uid}`;
17
+ this._connected = false;
18
+ this._reflecting = false;
19
+ this._value = "";
20
+ this._input = null;
21
+ /** @type {DateFieldController | null} */
22
+ this._field = null;
23
+ /** @type {DateFieldController[] | null} */
24
+ this._fields = null;
25
+ /** @type {DateRangeController | null} */
26
+ this._range = null;
27
+ this._orderTags = new Set();
28
+ /** @type {"" | "start" | "end"} */
29
+ this._lastFocusEndpoint = "";
30
+ this._rangeCommitId = 0;
31
+ /** Map of clicked/keyboard-activated grid dates to the generation at click
32
+ * time (range mode supersession/stale protection). @type {Map<string, number>} */
33
+ this._pendingIntents = new Map();
34
+ this._button = null;
35
+ this._panel = null;
36
+ this._calendar = null;
37
+ this._originalDescribedBy = "";
38
+ this._rangeOriginalDescribedBy = ["", ""];
39
+ this._formatHint = null;
40
+ this._generatedPlaceholder = false;
41
+ this._controller = null;
42
+ this._stopTracking = null;
43
+ this._open = false;
44
+ this._suppressFocusOpen = false;
45
+ this._messages = getDefaultMessages();
46
+ /** @type {any} */
47
+ this._source = null;
48
+ /** @type {any} */
49
+ this._dateState = null;
50
+ /** @type {any} */
51
+ this._renderDay = null;
52
+ /** @type {any} */
53
+ this._isDateDisabled = null;
54
+ }
55
+
56
+ _rangeMode() {
57
+ return this.hasAttribute("range") && Boolean(this._range);
58
+ }
59
+
60
+ connectedCallback() {
61
+ if (this._connected) return;
62
+ if (this.hasAttribute("range")) {
63
+ this._connectRange();
64
+ return;
65
+ }
66
+ this._connectSingle();
67
+ }
68
+
69
+ _connectSingle() {
70
+ const input = this.querySelector(":scope > input:not([type=hidden])");
71
+ if (!(input instanceof HTMLInputElement)) {
72
+ console.warn("<date-picker> expects a direct child text input");
73
+ return;
74
+ }
75
+ this._connected = true;
76
+ this._input = input;
77
+ this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
78
+ this._field.onAttributesChanged = () => this._syncInputState();
79
+ this._field.confirm = (date) => this._confirmDate(date);
80
+ this._setupFormValue();
81
+ this._build();
82
+ this._syncInputState();
83
+ this._bind();
84
+ this._syncCalendarOptions();
85
+
86
+ const adapter = this._adapter();
87
+ const initial =
88
+ this.getAttribute("value") || (isDate(input.value) ? input.value : adapter.parse(input.value));
89
+ if (initial && isDate(initial)) this._setValue(initial, { emit: false, format: true });
90
+ else if (!input.value) this._setValue("", { emit: false, format: false });
91
+ else input.setCustomValidity(this._messages.invalidDate);
92
+
93
+ // Native form reset restores control defaultValues, then _restoreDefault()
94
+ // recommits the canonical value from them. Never redefine an author-provided
95
+ // input.defaultValue here: the next reset must use the native default (which
96
+ // the app may change). Only when enhancement itself supplies the initial
97
+ // text (picker value attr, no input value attr) does that text become the
98
+ // default to restore.
99
+ if (this._field.hidden) this._field.hidden.defaultValue = this._value;
100
+ if (input.getAttribute("value") == null && input.defaultValue === "") {
101
+ input.defaultValue = input.value;
102
+ }
103
+ }
104
+
105
+ _connectRange() {
106
+ const startInput = this.querySelector(":scope > input[data-range-start]");
107
+ const endInput = this.querySelector(":scope > input[data-range-end]");
108
+ if (!(startInput instanceof HTMLInputElement) || !(endInput instanceof HTMLInputElement)) {
109
+ console.warn(
110
+ "<date-picker range> expects direct child inputs marked [data-range-start] and [data-range-end]",
111
+ );
112
+ return;
113
+ }
114
+ this._connected = true;
115
+ this._input = null;
116
+ this._fields = [
117
+ new DateFieldController(startInput, { messages: this._messages, locale: this.locale }),
118
+ new DateFieldController(endInput, { messages: this._messages, locale: this.locale }),
119
+ ];
120
+ this._range = new DateRangeController();
121
+ this._rangeOriginalDescribedBy = [
122
+ startInput.getAttribute("aria-describedby") || "",
123
+ endInput.getAttribute("aria-describedby") || "",
124
+ ];
125
+ for (const field of this._fields) {
126
+ field.onAttributesChanged = () => this._syncRangeFields();
127
+ field.confirm = (date) => this._confirmDate(date);
128
+ field.setupFormValue();
129
+ }
130
+ this._build(endInput);
131
+ this._setRangeAria();
132
+ this._syncRangeFields();
133
+ this._bindRange();
134
+ this._syncCalendarOptions();
135
+
136
+ const adapter = this._adapter();
137
+ const initialStart = isDate(startInput.value) ? startInput.value : adapter.parse(startInput.value);
138
+ const initialEnd = isDate(endInput.value) ? endInput.value : adapter.parse(endInput.value);
139
+ this._setRange(initialStart || "", initialEnd || "", { emit: false });
140
+ if (startInput.value.trim() && !isDate(initialStart))
141
+ startInput.setCustomValidity(this._messages.invalidDate);
142
+ if (endInput.value.trim() && !isDate(initialEnd)) endInput.setCustomValidity(this._messages.invalidDate);
143
+
144
+ if (this._fields[0].hidden) this._fields[0].hidden.defaultValue = this._range.start;
145
+ if (this._fields[1].hidden) this._fields[1].hidden.defaultValue = this._range.end;
146
+ if (startInput.getAttribute("value") == null && startInput.defaultValue === "") {
147
+ startInput.defaultValue = startInput.value;
148
+ }
149
+ if (endInput.getAttribute("value") == null && endInput.defaultValue === "") {
150
+ endInput.defaultValue = endInput.value;
151
+ }
152
+ }
153
+
154
+ disconnectedCallback() {
155
+ this._connected = false;
156
+ this.hide(false);
157
+ this._controller?.abort();
158
+ this._controller = null;
159
+ if (this._rangeMode()) {
160
+ for (const field of this._fields || []) this._teardownField(field);
161
+ this._fields = null;
162
+ this._range = null;
163
+ } else {
164
+ this._teardownField(this._field);
165
+ }
166
+ this._field = null;
167
+ this._button?.remove();
168
+ this._panel?.remove();
169
+ this._formatHint?.remove();
170
+ this._button = null;
171
+ this._panel = null;
172
+ this._calendar = null;
173
+ this._formatHint = null;
174
+ }
175
+
176
+ /** @param {DateFieldController | null} field */
177
+ _teardownField(field) {
178
+ if (!field) return;
179
+ const input = field.input;
180
+ const original = this._fields?.includes(field)
181
+ ? this._rangeOriginalDescribedBy[this._fields?.indexOf(field) ?? 0] || ""
182
+ : this._originalDescribedBy;
183
+ field.teardown();
184
+ if (original) input.setAttribute("aria-describedby", original);
185
+ else input.removeAttribute("aria-describedby");
186
+ input.removeAttribute("role");
187
+ input.removeAttribute("aria-haspopup");
188
+ input.removeAttribute("aria-expanded");
189
+ input.removeAttribute("aria-controls");
190
+ }
191
+
192
+ /** @param {string} name @param {string|null} oldValue @param {string|null} newValue */
193
+ attributeChangedCallback(name, oldValue, newValue) {
194
+ if (!this._connected || this._reflecting || oldValue === newValue) return;
195
+ if (name === "value") {
196
+ if (this._rangeMode()) return;
197
+ if (!newValue || isDate(newValue))
198
+ this._setValue(newValue || "", { emit: false, format: true, reflect: false });
199
+ return;
200
+ }
201
+ if (name === "locale") this._refreshLocale();
202
+ if (name === "open-on-focus") return;
203
+ this._syncCalendarOptions();
204
+ void this.validate();
205
+ }
206
+
207
+ /** @public */
208
+ get input() {
209
+ return this._rangeMode() ? undefined : this._input;
210
+ }
211
+
212
+ /** @public */
213
+ get calendar() {
214
+ return this._calendar;
215
+ }
216
+
217
+ /** @public */
218
+ get value() {
219
+ return this._rangeMode() ? undefined : this._value;
220
+ }
221
+
222
+ set value(value) {
223
+ if (this._rangeMode()) {
224
+ throw new TypeError("value is not available in range mode; use range");
225
+ }
226
+ if (value && !isDate(value)) throw new TypeError(`Invalid date-picker value: ${value}`);
227
+ this._setValue(value || "", { emit: false, format: true });
228
+ }
229
+
230
+ /** @public */
231
+ get range() {
232
+ return this._rangeMode() && this._range ? { ...this._range.range } : undefined;
233
+ }
234
+
235
+ set range(value) {
236
+ if (!this._rangeMode()) throw new TypeError("range is only available on <date-picker range>");
237
+ const { start, end } = normalizeRange(value);
238
+ this._setRange(start, end);
239
+ }
240
+
241
+ /** @public */
242
+ get locale() {
243
+ return resolveLocale(this.getAttribute("locale") || "");
244
+ }
245
+
246
+ set locale(value) {
247
+ if (value) this.setAttribute("locale", value);
248
+ else this.removeAttribute("locale");
249
+ }
250
+
251
+ /** @public */
252
+ get min() {
253
+ const value = this.getAttribute("min") || "";
254
+ return isDate(value) ? value : "";
255
+ }
256
+
257
+ set min(value) {
258
+ if (value) this.setAttribute("min", value);
259
+ else this.removeAttribute("min");
260
+ }
261
+
262
+ /** @public */
263
+ get max() {
264
+ const value = this.getAttribute("max") || "";
265
+ return isDate(value) ? value : "";
266
+ }
267
+
268
+ set max(value) {
269
+ if (value) this.setAttribute("max", value);
270
+ else this.removeAttribute("max");
271
+ }
272
+
273
+ /** @public */
274
+ get openOnFocus() {
275
+ return this.getAttribute("open-on-focus") !== "false";
276
+ }
277
+
278
+ set openOnFocus(value) {
279
+ if (value === false) this.setAttribute("open-on-focus", "false");
280
+ else this.removeAttribute("open-on-focus");
281
+ }
282
+
283
+ /** @public */
284
+ get open() {
285
+ return this._open;
286
+ }
287
+
288
+ /** @public */
289
+ get messages() {
290
+ return this._messages;
291
+ }
292
+
293
+ set messages(value) {
294
+ this._messages = { ...getDefaultMessages(), ...(value || {}) };
295
+ if (this._calendar) this._calendar.messages = this._messages;
296
+ for (const field of this._fields || []) field.setMessages(this._messages);
297
+ this._field?.setMessages(this._messages);
298
+ this._refreshButtonLabel();
299
+ void this.validate();
300
+ }
301
+
302
+ /** @public @returns {any} */
303
+ get source() {
304
+ return this._source;
305
+ }
306
+
307
+ set source(value) {
308
+ this._source = value || null;
309
+ if (this._calendar) this._calendar.source = this._source;
310
+ if (this._connected) void this.validate();
311
+ }
312
+
313
+ /** @public @returns {any} */
314
+ get dateState() {
315
+ return this._dateState;
316
+ }
317
+
318
+ set dateState(value) {
319
+ this._dateState = typeof value === "function" ? value : null;
320
+ if (this._calendar) this._calendar.dateState = this._dateState;
321
+ if (this._connected) void this.validate();
322
+ }
323
+
324
+ /** @public @returns {any} */
325
+ get renderDay() {
326
+ return this._renderDay;
327
+ }
328
+
329
+ set renderDay(value) {
330
+ this._renderDay = typeof value === "function" ? value : null;
331
+ if (this._calendar) this._calendar.renderDay = this._renderDay;
332
+ }
333
+
334
+ /** @public @returns {any} */
335
+ get isDateDisabled() {
336
+ return this._isDateDisabled;
337
+ }
338
+
339
+ set isDateDisabled(value) {
340
+ this._isDateDisabled = typeof value === "function" ? value : null;
341
+ if (this._calendar) this._calendar.isDateDisabled = this._isDateDisabled;
342
+ if (this._connected) void this.validate();
343
+ }
344
+
345
+ _adapter() {
346
+ return this._field?.adapter ?? this._fields?.[0]?.adapter ?? createDateAdapter(this.locale);
347
+ }
348
+
349
+ _setupFormValue() {
350
+ this._field?.setupFormValue();
351
+ }
352
+
353
+ _syncInputState() {
354
+ const input = this._input;
355
+ if (!input) return;
356
+ // The hidden canonical field owns submission; dynamic name/form/disabled
357
+ // movement lives in the field controller.
358
+ this._field?.syncInputState();
359
+ if (this._button) this._button.disabled = input.disabled || input.readOnly;
360
+ if (this._open && (input.disabled || input.readOnly)) this.hide(false);
361
+ }
362
+
363
+ _syncRangeFields() {
364
+ if (!this._fields) return;
365
+ for (const field of this._fields) field.syncInputState();
366
+ const active = this._range?.activeEndpoint === "end" ? 1 : 0;
367
+ const disabled = this._fields.every((field) => field.input.disabled || field.input.readOnly);
368
+ if (this._button) this._button.disabled = disabled;
369
+ if (this._open && (this._fields[active]?.input.disabled || this._fields[active]?.input.readOnly)) {
370
+ this.hide(false);
371
+ }
372
+ }
373
+
374
+ _setRangeAria() {
375
+ if (!this._fields || !this._panel || !this._formatHint) return;
376
+ for (const field of this._fields) {
377
+ const input = field.input;
378
+ const describedBy = [this._rangeOriginalDescribedBy[this._fields.indexOf(field)], this._formatHint.id]
379
+ .filter(Boolean)
380
+ .join(" ");
381
+ input.setAttribute("aria-describedby", describedBy);
382
+ input.setAttribute("role", "combobox");
383
+ input.setAttribute("aria-haspopup", "dialog");
384
+ input.setAttribute("aria-expanded", "false");
385
+ input.setAttribute("aria-controls", this._panel.id);
386
+ }
387
+ }
388
+
389
+ /** @param {HTMLInputElement} [anchorInput] */
390
+ _build(anchorInput) {
391
+ const input = anchorInput || this._input;
392
+ if (!input) return;
393
+ const panelId = `${this._id}-panel`;
394
+ const hintId = `${this._id}-format`;
395
+ const button = document.createElement("button");
396
+ button.type = "button";
397
+ button.className = "dp-picker-button";
398
+ button.setAttribute("aria-haspopup", "dialog");
399
+ button.setAttribute("aria-expanded", "false");
400
+ button.setAttribute("aria-controls", panelId);
401
+ button.innerHTML =
402
+ '<span aria-hidden="true"><svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><rect x="1.75" y="3" width="12.5" height="11" rx="1.75"/><path d="M1.75 6.75h12.5"/><path d="M5.25 1.75v2.5M10.75 1.75v2.5"/></svg></span>';
403
+
404
+ const panel = document.createElement("div");
405
+ panel.id = panelId;
406
+ panel.className = "dp-picker-panel";
407
+ panel.setAttribute("popover", "manual");
408
+ panel.setAttribute("role", "dialog");
409
+ panel.setAttribute("aria-label", this._messages.calendar);
410
+
411
+ const calendar = new DateCalendarElement();
412
+ calendar.selection = "none";
413
+ calendar.fixedWeeks = true;
414
+ panel.append(calendar);
415
+
416
+ const hint = document.createElement("span");
417
+ hint.id = hintId;
418
+ hint.className = "dp-visually-hidden";
419
+
420
+ input.insertAdjacentElement("afterend", button);
421
+ button.insertAdjacentElement("afterend", panel);
422
+ panel.insertAdjacentElement("afterend", hint);
423
+
424
+ this._button = button;
425
+ this._panel = panel;
426
+ this._calendar = calendar;
427
+ this._formatHint = hint;
428
+
429
+ if (!this._rangeMode()) {
430
+ this._originalDescribedBy = input.getAttribute("aria-describedby") || "";
431
+ const describedBy = [this._originalDescribedBy, hintId].filter(Boolean).join(" ");
432
+ input.setAttribute("aria-describedby", describedBy);
433
+ input.setAttribute("role", "combobox");
434
+ input.setAttribute("aria-haspopup", "dialog");
435
+ input.setAttribute("aria-expanded", "false");
436
+ input.setAttribute("aria-controls", panelId);
437
+ }
438
+ this._refreshLocale();
439
+ this._refreshButtonLabel();
440
+ }
441
+
442
+ _bind() {
443
+ const input = this._input;
444
+ const button = this._button;
445
+ const calendar = this._calendar;
446
+ if (!input || !button || !calendar) return;
447
+ const controller = new AbortController();
448
+ this._controller = controller;
449
+ const { signal } = controller;
450
+
451
+ input.addEventListener(
452
+ "input",
453
+ () => {
454
+ // Raw text is no longer the validated canonical value: the old ISO
455
+ // must never submit (requestSubmit/FormData can bypass blur/change).
456
+ this._field?.handleInput();
457
+ },
458
+ { signal },
459
+ );
460
+ input.addEventListener("change", () => void this._commitText(false), { signal });
461
+ input.addEventListener("blur", () => void this._commitText(false), { signal });
462
+ input.addEventListener(
463
+ "focus",
464
+ (event) => {
465
+ // Opening on focus never steals focus: the user can start typing
466
+ // immediately (DECISIONS D4). Programmatic focus restores are guarded
467
+ // through _suppressFocusOpen and the relatedTarget containment check.
468
+ if (!this.openOnFocus || this._suppressFocusOpen) return;
469
+ const related = /** @type {FocusEvent} */ (event).relatedTarget;
470
+ if (related instanceof Node && this.contains(related)) return;
471
+ if (!this._open) this.show({ moveFocus: false });
472
+ },
473
+ { signal },
474
+ );
475
+ input.addEventListener(
476
+ "keydown",
477
+ (event) => {
478
+ this._onFieldKeyDown("", event);
479
+ },
480
+ { signal },
481
+ );
482
+ button.addEventListener(
483
+ "click",
484
+ (event) => {
485
+ if (this._open) {
486
+ // Keyboard activation (detail 0) of an already-open picker moves into
487
+ // the grid instead of toggling it shut. This matters under the default
488
+ // open-on-focus, where the popup is already open when Tab reaches the
489
+ // button; Escape keeps closing it. Defer focus as in the input handler.
490
+ if (event.detail === 0) {
491
+ setTimeout(() => this._calendar?.focusGrid(), 0);
492
+ return;
493
+ }
494
+ this.hide(false);
495
+ return;
496
+ }
497
+ this.show();
498
+ },
499
+ { signal },
500
+ );
501
+ calendar.addEventListener(
502
+ "dateactivate",
503
+ (event) => {
504
+ const custom = /** @type {CustomEvent} */ (event);
505
+ const date = custom.detail?.date;
506
+ if (!isDate(date)) return;
507
+ // The app listener on <date-picker> runs after this during bubbling;
508
+ // defer the commit so preventDefault() is actually respected.
509
+ queueMicrotask(() => {
510
+ if (custom.defaultPrevented || !this._connected) return;
511
+ if (this._input?.disabled || this._input?.readOnly) return;
512
+ this._field?.dirty();
513
+ this._setValue(date, { emit: true, format: true });
514
+ this.hide(false);
515
+ this._focusInput();
516
+ });
517
+ },
518
+ { signal },
519
+ );
520
+ calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
521
+ this.ownerDocument.addEventListener(
522
+ "pointerdown",
523
+ (event) => {
524
+ if (!this._open || event.composedPath().includes(this)) return;
525
+ this.hide(false);
526
+ },
527
+ { capture: true, signal },
528
+ );
529
+ this.ownerDocument.addEventListener(
530
+ "reset",
531
+ (event) => {
532
+ const form = event.target;
533
+ if (form instanceof HTMLFormElement && this._input && form === this._input.form) {
534
+ // The reset event fires before the control values are restored and
535
+ // is cancelable; only resync when the reset actually applies.
536
+ this.hide(false);
537
+ queueMicrotask(() => {
538
+ if (!event.defaultPrevented) this._restoreDefault();
539
+ });
540
+ }
541
+ },
542
+ { capture: true, signal },
543
+ );
544
+ this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
545
+ }
546
+
547
+ _bindRange() {
548
+ const button = this._button;
549
+ const calendar = this._calendar;
550
+ if (!this._fields || !button || !calendar) return;
551
+ const controller = new AbortController();
552
+ this._controller = controller;
553
+ const { signal } = controller;
554
+
555
+ for (const [index, field] of this._fields.entries()) {
556
+ const endpoint = index === 1 ? "end" : "start";
557
+ field.input.addEventListener("input", () => field.handleInput(), { signal });
558
+ field.input.addEventListener("change", () => void this._commitFieldText(endpoint), { signal });
559
+ field.input.addEventListener("blur", () => void this._commitFieldText(endpoint), { signal });
560
+ field.input.addEventListener(
561
+ "focus",
562
+ (event) => this._onFieldFocus(endpoint, /** @type {FocusEvent} */ (event)),
563
+ { signal },
564
+ );
565
+ field.input.addEventListener("keydown", (event) => this._onFieldKeyDown(endpoint, event), { signal });
566
+ }
567
+ button.addEventListener(
568
+ "click",
569
+ (event) => {
570
+ if (this._open) {
571
+ if (event.detail === 0) {
572
+ setTimeout(() => this._calendar?.focusGrid(), 0);
573
+ return;
574
+ }
575
+ this.hide(false);
576
+ return;
577
+ }
578
+ this.show();
579
+ },
580
+ { signal },
581
+ );
582
+ // Record grid-activation intent synchronously (click or Enter/Space). The
583
+ // picker only commits a dateactivate whose generation still matches, so a
584
+ // response that resolves after the active endpoint changed (or after a
585
+ // newer activation) is dropped without touching either bound.
586
+ calendar.addEventListener("click", (event) => this._captureGridIntent(event), { capture: true, signal });
587
+ calendar.addEventListener("keydown", (event) => this._captureGridIntent(event), {
588
+ capture: true,
589
+ signal,
590
+ });
591
+ calendar.addEventListener(
592
+ "dateactivate",
593
+ (event) => {
594
+ const custom = /** @type {CustomEvent} */ (event);
595
+ const date = custom.detail?.date;
596
+ if (!isDate(date)) return;
597
+ queueMicrotask(() => {
598
+ if (custom.defaultPrevented || !this._connected || !this._range) return;
599
+ const pending = this._pendingIntents.get(date);
600
+ // A response is dropped when the active endpoint changed, the popup
601
+ // closed or a newer activation superseded this one.
602
+ if (pending === undefined || pending !== this._rangeCommitId) return;
603
+ this._pendingIntents.delete(date);
604
+ const result = this._range.activate(date, (which) => {
605
+ const index = which === "end" ? 1 : 0;
606
+ const field = this._fields?.[index];
607
+ return field != null && !field.input.disabled && !field.input.readOnly;
608
+ });
609
+ if (result.status === "refused") {
610
+ this._calendar?.dispatchEvent(
611
+ new CustomEvent("dateinvalid", {
612
+ detail: { date, state: this._calendar.getDateState(date) },
613
+ bubbles: true,
614
+ }),
615
+ );
616
+ return;
617
+ }
618
+ this._setBound(result.endpoint, date, { emit: true, user: true });
619
+ if (result.close || result.status === "complete") {
620
+ this.hide(false);
621
+ this._focusField(result.endpoint);
622
+ }
623
+ });
624
+ },
625
+ { signal },
626
+ );
627
+ calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
628
+ this.ownerDocument.addEventListener(
629
+ "pointerdown",
630
+ (event) => {
631
+ if (!this._open || event.composedPath().includes(this)) return;
632
+ this.hide(false);
633
+ },
634
+ { capture: true, signal },
635
+ );
636
+ this.ownerDocument.addEventListener(
637
+ "reset",
638
+ (event) => {
639
+ const form = event.target;
640
+ if (form instanceof HTMLFormElement && this._fields && form === this._fields[0].input.form) {
641
+ this.hide(false);
642
+ queueMicrotask(() => {
643
+ if (!event.defaultPrevented) this._restoreRangeDefault();
644
+ });
645
+ }
646
+ },
647
+ { capture: true, signal },
648
+ );
649
+ this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
650
+ }
651
+
652
+ /** @param {"" | "start" | "end"} endpoint @param {KeyboardEvent} event */
653
+ _onFieldKeyDown(endpoint, event) {
654
+ if (event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown")) {
655
+ event.preventDefault();
656
+ if (this._rangeMode() && endpoint) {
657
+ this._lastFocusEndpoint = endpoint;
658
+ this._range?.focus(endpoint);
659
+ }
660
+ if (this._open) {
661
+ // Chromium drops script-initiated focus changes made during keydown
662
+ // dispatch; defer to a macrotask so the grid can take keyboard focus.
663
+ setTimeout(() => this._calendar?.focusGrid(), 0);
664
+ } else {
665
+ this.show();
666
+ }
667
+ } else if (event.key === "Escape" && this._open) {
668
+ event.preventDefault();
669
+ this.hide(false);
670
+ }
671
+ }
672
+
673
+ /** @param {"" | "start" | "end"} endpoint @param {FocusEvent} event */
674
+ _onFieldFocus(endpoint, event) {
675
+ if (!this._rangeMode() || !endpoint) return;
676
+ const switched = endpoint !== this._range?.activeEndpoint;
677
+ this._lastFocusEndpoint = endpoint;
678
+ this._range?.focus(endpoint);
679
+ if (switched) {
680
+ // A late calendar activation targeting the previous endpoint must not
681
+ // commit after the intent moved to the other bound.
682
+ this._rangeCommitId++;
683
+ this._pendingIntents.clear();
684
+ }
685
+ if (!this.openOnFocus || this._suppressFocusOpen) return;
686
+ const related = event.relatedTarget;
687
+ if (related instanceof Node && this.contains(related)) return;
688
+ if (!this._open) this.show({ moveFocus: false });
689
+ }
690
+
691
+ /** Record range-mode grid activation intent (click or keyboard) so a stale,
692
+ * late-confirmed dateactivate can be recognized and dropped. Each new grid
693
+ * activation supersedes every earlier pending one.
694
+ * @param {Event} event */
695
+ _captureGridIntent(event) {
696
+ if (!this._rangeMode() || !this._range) return;
697
+ if (event.type === "keydown") {
698
+ const key = /** @type {KeyboardEvent} */ (event).key;
699
+ if (key !== "Enter" && key !== " ") return;
700
+ }
701
+ if (!(event.target instanceof Element)) return;
702
+ const cell = event.target.closest(".dp-day[data-date]");
703
+ const date = cell?.getAttribute("data-date") || "";
704
+ if (!isDate(date)) return;
705
+ this._pendingIntents.set(date, ++this._rangeCommitId);
706
+ }
707
+
708
+ /** @param {KeyboardEvent} event */
709
+ _onEscape(event) {
710
+ if (event.key === "Escape" && this._open) {
711
+ event.preventDefault();
712
+ event.stopPropagation();
713
+ this.hide(true);
714
+ }
715
+ }
716
+
717
+ _syncCalendarOptions() {
718
+ const calendar = this._calendar;
719
+ const panel = this._panel;
720
+ if (!calendar) return;
721
+ calendar.locale = this.locale;
722
+ calendar.min = this.min;
723
+ calendar.max = this.max;
724
+ calendar.messages = this._messages;
725
+ calendar.source = this._source;
726
+ calendar.dateState = this._dateState;
727
+ calendar.renderDay = this._renderDay;
728
+ calendar.isDateDisabled = this._isDateDisabled;
729
+ if (!this._rangeMode() && this._value) calendar.value = this._value;
730
+ if (panel) panel.setAttribute("aria-label", this._messages.calendar);
731
+ }
732
+
733
+ _refreshLocale() {
734
+ const hint = this._formatHint;
735
+ if (!hint) return;
736
+ if (this._rangeMode() && this._fields) {
737
+ this._fields[0].setLocale(this.locale);
738
+ this._fields[1].setLocale(this.locale);
739
+ hint.textContent = `${this._messages.formatHint}: ${this._fields[0].placeholder}`;
740
+ for (const [index, field] of this._fields.entries()) {
741
+ if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
742
+ field.input.placeholder = field.placeholder;
743
+ this._generatedPlaceholder = true;
744
+ }
745
+ const canonical = index === 1 ? this._range?.end || "" : this._range?.start || "";
746
+ if (canonical) field.input.value = field.format(canonical);
747
+ }
748
+ this._refreshButtonLabel();
749
+ return;
750
+ }
751
+ const field = this._field;
752
+ if (!field) return;
753
+ field.setLocale(this.locale);
754
+ hint.textContent = `${this._messages.formatHint}: ${field.placeholder}`;
755
+ if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
756
+ field.input.placeholder = field.placeholder;
757
+ this._generatedPlaceholder = true;
758
+ }
759
+ if (this._value) field.input.value = field.format(this._value);
760
+ this._refreshButtonLabel();
761
+ }
762
+
763
+ _refreshButtonLabel() {
764
+ if (!this._button) return;
765
+ if (this._rangeMode()) {
766
+ this._refreshRangeButtonLabel();
767
+ return;
768
+ }
769
+ const prefix = this._value ? this._messages.changeDate : this._messages.chooseDate;
770
+ const suffix = this._value ? `, ${formatLongDate(this._value, this.locale)}` : "";
771
+ this._button.setAttribute("aria-label", `${prefix}${suffix}`);
772
+ }
773
+
774
+ _refreshRangeButtonLabel() {
775
+ if (!this._button || !this._range) return;
776
+ const { start, end } = this._range;
777
+ const hasRange = Boolean(start && end);
778
+ const prefix = hasRange ? this._messages.changeDate : this._messages.chooseDate;
779
+ const suffix = hasRange
780
+ ? `, ${formatLongDate(start, this.locale)} – ${formatLongDate(end, this.locale)}`
781
+ : "";
782
+ this._button.setAttribute("aria-label", `${prefix}${suffix}`);
783
+ }
784
+
785
+ /** @param {string} value */
786
+ _reflectValue(value) {
787
+ this._reflecting = true;
788
+ if (value) this.setAttribute("value", value);
789
+ else this.removeAttribute("value");
790
+ this._reflecting = false;
791
+ }
792
+
793
+ /** @param {string} value @param {{emit?:boolean,format?:boolean,reflect?:boolean}} [options] */
794
+ _setValue(value, options = {}) {
795
+ if (value && !isDate(value)) throw new TypeError(`Invalid date-picker value: ${value}`);
796
+ const previous = this._value;
797
+ this._value = value || "";
798
+ if (options.reflect !== false) this._reflectValue(this._value);
799
+ this._field?.setCanonical(this._value, { format: options.format !== false });
800
+ if (this._calendar) {
801
+ this._calendar.value = this._value;
802
+ if (this._value) this._calendar.focusedDate = this._value;
803
+ }
804
+ this._refreshButtonLabel();
805
+ if (previous !== this._value) {
806
+ this.dispatchEvent(new CustomEvent("valuechange", { detail: { value: this._value }, bubbles: true }));
807
+ if (options.emit && this._field) {
808
+ const input = this._field.input;
809
+ input.dispatchEvent(new Event("input", { bubbles: true }));
810
+ input.dispatchEvent(new Event("change", { bubbles: true }));
811
+ }
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Calendar-backed availability gate injected into the field controllers.
817
+ * A cancelled or failed source load confirms nothing: do not fill the
818
+ * submitted ISO field nor treat the date as available.
819
+ * @param {string} date @returns {Promise<{ok: boolean, message?: string}>}
820
+ */
821
+ async _confirmDate(date) {
822
+ const calendar = this._calendar;
823
+ if (!calendar) return { ok: true };
824
+ const display = monthKey(date);
825
+ if (calendar.display !== display) calendar.display = display;
826
+ const confirmed = await calendar.ensureDate(date);
827
+ const state = calendar.getDateState(date);
828
+ if (!confirmed || state.disabled) return { ok: false, message: this._messages.unavailableDate };
829
+ return { ok: true };
830
+ }
831
+
832
+ /** @param {boolean} emit @param {{force?: boolean}} [options] */
833
+ async _commitText(emit, options = {}) {
834
+ const field = this._field;
835
+ if (!field) return false;
836
+ // Blur/change skip untouched text; explicit validation must recheck the
837
+ // current value against the latest constraints regardless of dirtiness.
838
+ if (!options.force && !field.isDirty) return false;
839
+ const result = await field.commit();
840
+ if (result.status === "stale" || result.status === "invalid") return false;
841
+ this._setValue(result.value || "", { emit, format: result.status === "ok" });
842
+ return true;
843
+ }
844
+
845
+ /** @param {"start" | "end"} which */
846
+ async _commitFieldText(which) {
847
+ const fields = this._fields;
848
+ if (!fields) return false;
849
+ const index = which === "end" ? 1 : 0;
850
+ const field = fields[index];
851
+ if (!field?.isDirty) return false;
852
+ const result = await field.commit();
853
+ if (result.status === "stale") return false;
854
+ if (result.status === "invalid") {
855
+ this._orderTags.delete(index);
856
+ return false;
857
+ }
858
+ this._setBound(which, result.value || "", { emit: true, user: true, format: result.status === "ok" });
859
+ return true;
860
+ }
861
+
862
+ /** @param {"start" | "end"} which @param {string} value
863
+ * @param {{emit?:boolean, format?:boolean, user?:boolean}} [options] @returns {boolean} */
864
+ _setBound(which, value, options = {}) {
865
+ if (!this._range) return false;
866
+ const fields = this._fields;
867
+ if (!fields) return false;
868
+ const index = which === "end" ? 1 : 0;
869
+ const field = fields[index];
870
+ if (!field) return false;
871
+ // User initiated picks must respect disabled/readonly bounds; reset,
872
+ // explicit validation and programmatic assignments keep their own path.
873
+ if (options.user === true && (field.input.disabled || field.input.readOnly)) return false;
874
+ const next = value || "";
875
+ // The range model may already hold the target (a calendar activation moved
876
+ // it), so change detection reads the field canonical, not the model.
877
+ const previous = field.canonical;
878
+ field.setCanonical(next, { format: options.format !== false });
879
+ if (which === "end") this._range.end = next;
880
+ else this._range.start = next;
881
+ const changed = previous !== next;
882
+ if (changed) {
883
+ this._syncHighlight();
884
+ this._refreshRangeButtonLabel();
885
+ this._emitRangeChange();
886
+ if (options.emit) {
887
+ field.input.dispatchEvent(new Event("input", { bubbles: true }));
888
+ field.input.dispatchEvent(new Event("change", { bubbles: true }));
889
+ }
890
+ }
891
+ this._revalidateRange(which);
892
+ return changed;
893
+ }
894
+
895
+ /** Atomic pair update: canonical + visible + hidden before one event.
896
+ * @param {string} start @param {string} end @param {{emit?:boolean, format?:boolean}} [options] */
897
+ _setRange(start, end, options = {}) {
898
+ if (!this._range || !this._fields) return;
899
+ this._range.start = start || "";
900
+ this._range.end = end || "";
901
+ this._fields[0].setCanonical(start || "", { format: options.format !== false });
902
+ this._fields[1].setCanonical(end || "", { format: options.format !== false });
903
+ this._syncHighlight();
904
+ this._refreshRangeButtonLabel();
905
+ this._revalidateRange("start");
906
+ this._revalidateRange("end");
907
+ if (options.emit !== false) this._emitRangeChange();
908
+ }
909
+
910
+ /** @param {"start" | "end"} which */
911
+ _boundCanonical(which) {
912
+ return this._range ? (which === "end" ? this._range.end : this._range.start) : "";
913
+ }
914
+
915
+ _emitRangeChange() {
916
+ if (!this._range) return;
917
+ this.dispatchEvent(new CustomEvent("rangechange", { detail: { ...this._range.range }, bubbles: true }));
918
+ }
919
+
920
+ /** Forward only a displayable range to the calendar band. An inverted or
921
+ * start-less business range shows no misleading band. */
922
+ _syncHighlight() {
923
+ if (!this._calendar || !this._range) return;
924
+ const { start, end } = this._range;
925
+ if (!start) {
926
+ this._calendar.highlightedRange = { start: "", end: "" };
927
+ return;
928
+ }
929
+ if (end && compareDates(end, start) < 0) {
930
+ this._calendar.highlightedRange = { start, end: "" };
931
+ } else {
932
+ this._calendar.highlightedRange = { start, end };
933
+ }
934
+ }
935
+
936
+ /**
937
+ * Cross-bound validity: the order error is attributed to the bound that was
938
+ * just modified; the other bound only loses a stale order error, never its
939
+ * independent parse/source validity.
940
+ * @param {"start" | "end"} which
941
+ */
942
+ _revalidateRange(which) {
943
+ if (!this._fields || !this._range) return;
944
+ const { start, end } = this._range;
945
+ const inverted = Boolean(start && end && compareDates(end, start) < 0);
946
+ const bounds = ["start", "end"];
947
+ for (const bound of bounds) {
948
+ const index = bound === "end" ? 1 : 0;
949
+ const input = this._fields[index].input;
950
+ const message = bound === "end" ? this._messages.rangeOrderEnd : this._messages.rangeOrderStart;
951
+ const shouldHaveOrder = inverted && bound === which;
952
+ if (shouldHaveOrder) {
953
+ input.setCustomValidity(message);
954
+ this._orderTags.add(index);
955
+ } else if (this._orderTags.has(index)) {
956
+ input.setCustomValidity("");
957
+ this._orderTags.delete(index);
958
+ }
959
+ }
960
+ }
961
+
962
+ _restoreDefault() {
963
+ const field = this._field;
964
+ if (!field) return;
965
+ const value = field.restoreDefault();
966
+ this._setValue(value, { emit: false, format: true });
967
+ if (this._calendar) {
968
+ if (this._value) {
969
+ this._calendar.display = monthKey(this._value);
970
+ this._calendar.focusedDate = this._value;
971
+ } else {
972
+ this._calendar.display = monthKey(todayISO());
973
+ this._calendar.focusedDate = todayISO();
974
+ }
975
+ }
976
+ void this.validate();
977
+ }
978
+
979
+ _restoreRangeDefault() {
980
+ if (!this._fields) return;
981
+ const start = this._fields[0].restoreDefault();
982
+ const end = this._fields[1].restoreDefault();
983
+ this._setRange(start, end);
984
+ if (this._calendar) {
985
+ const target = this._range?.start || todayISO();
986
+ this._calendar.display = monthKey(target);
987
+ this._calendar.focusedDate = target;
988
+ }
989
+ void this.validate();
990
+ }
991
+
992
+ /** @param {"start" | "end"} which */
993
+ _focusField(which) {
994
+ const index = which === "end" ? 1 : 0;
995
+ const input = this._fields?.[index]?.input;
996
+ if (!input) return;
997
+ this._suppressFocusOpen = true;
998
+ setTimeout(() => {
999
+ input.focus();
1000
+ this._suppressFocusOpen = false;
1001
+ }, 0);
1002
+ }
1003
+
1004
+ _focusInput() {
1005
+ // focus() must not run synchronously from a keydown handler (Chromium drops
1006
+ // focus changes there) and must not re-open the popover under the default
1007
+ // open-on-focus. Deferring keeps _suppressFocusOpen active for the call.
1008
+ this._suppressFocusOpen = true;
1009
+ setTimeout(() => {
1010
+ this._input?.focus();
1011
+ this._suppressFocusOpen = false;
1012
+ }, 0);
1013
+ }
1014
+
1015
+ /** @returns {"" | "start" | "end"} */
1016
+ _resolveRangeEndpoint() {
1017
+ if (!this._fields) return "";
1018
+ const preferred = this._lastFocusEndpoint || "start";
1019
+ const preferredIndex = preferred === "end" ? 1 : 0;
1020
+ if (!this._fields[preferredIndex].input.disabled && !this._fields[preferredIndex].input.readOnly) {
1021
+ return preferred;
1022
+ }
1023
+ const alternative = preferred === "end" ? "start" : "end";
1024
+ const alternativeIndex = alternative === "end" ? 1 : 0;
1025
+ if (!this._fields[alternativeIndex].input.disabled && !this._fields[alternativeIndex].input.readOnly) {
1026
+ return alternative;
1027
+ }
1028
+ return "";
1029
+ }
1030
+
1031
+ /** @public */
1032
+ async validate() {
1033
+ if (this._rangeMode()) {
1034
+ const fields = this._fields;
1035
+ if (!fields) return true;
1036
+ // One owner for values: each commit result is applied centrally so the
1037
+ // field controller, hidden ISO, range model and events stay coherent.
1038
+ // Empty text flows through commit()'s "clear" outcome, not an early exit.
1039
+ for (const [index, field] of fields.entries()) {
1040
+ const result = await field.commit();
1041
+ if (result.status === "ok" || result.status === "clear") {
1042
+ const which = index === 1 ? "end" : "start";
1043
+ this._setBound(which, result.value || "", { format: result.status === "ok" });
1044
+ }
1045
+ }
1046
+ this._revalidateRange("start");
1047
+ this._revalidateRange("end");
1048
+ return fields.every((field) => field.input.checkValidity());
1049
+ }
1050
+ const input = this._field?.input;
1051
+ if (!input) return true;
1052
+ if (!input.value.trim()) {
1053
+ input.setCustomValidity("");
1054
+ return input.checkValidity();
1055
+ }
1056
+ await this._commitText(false, { force: true });
1057
+ return input.checkValidity();
1058
+ }
1059
+
1060
+ /** @public @param {{moveFocus?:boolean}} [options] */
1061
+ show(options = {}) {
1062
+ const panel = this._panel;
1063
+ const calendar = this._calendar;
1064
+ const button = this._button;
1065
+ if (!panel || !calendar || !button || this._open) return;
1066
+ if (this._rangeMode()) {
1067
+ const endpoint = this._resolveRangeEndpoint();
1068
+ if (!endpoint) return;
1069
+ this._range?.focus(endpoint);
1070
+ this._rangeCommitId++;
1071
+ this._pendingIntents.clear();
1072
+ const target = this._boundCanonical(endpoint) || todayISO();
1073
+ calendar.display = monthKey(target);
1074
+ calendar.focusedDate = target;
1075
+ this._lastFocusEndpoint = endpoint;
1076
+ } else {
1077
+ const input = this._input;
1078
+ if (!input || input.disabled || input.readOnly) return;
1079
+ const target = this._value || this._adapter().parse(input.value) || todayISO();
1080
+ calendar.display = monthKey(target);
1081
+ calendar.focusedDate = target;
1082
+ }
1083
+ panel.showPopover();
1084
+ this._open = true;
1085
+ this._setExpanded(true);
1086
+ const position = () =>
1087
+ reposition(this, panel, { placement: "bottom-start", distance: 4, shiftPadding: 8 });
1088
+ position();
1089
+ this._stopTracking = autoUpdate(this, panel, position);
1090
+ if (options.moveFocus !== false) queueMicrotask(() => calendar.focusGrid());
1091
+ this.dispatchEvent(new Event("open", { bubbles: true }));
1092
+ }
1093
+
1094
+ /** @param {boolean} expanded */
1095
+ _setExpanded(expanded) {
1096
+ const value = expanded ? "true" : "false";
1097
+ this._input?.setAttribute("aria-expanded", value);
1098
+ this._button?.setAttribute("aria-expanded", value);
1099
+ for (const field of this._fields || []) field.input.setAttribute("aria-expanded", value);
1100
+ }
1101
+
1102
+ /** @public @param {boolean} [restoreFocus] */
1103
+ hide(restoreFocus = false) {
1104
+ if (!this._panel || !this._open) return;
1105
+ this._stopTracking?.();
1106
+ this._stopTracking = null;
1107
+ try {
1108
+ this._panel.hidePopover();
1109
+ } catch {
1110
+ // Already hidden by the UA.
1111
+ }
1112
+ this._open = false;
1113
+ this._setExpanded(false);
1114
+ this._rangeCommitId++;
1115
+ this._pendingIntents.clear();
1116
+ if (restoreFocus) {
1117
+ if (this._rangeMode()) {
1118
+ const endpoint = this._restoreFocusEndpoint();
1119
+ if (endpoint) this._focusField(endpoint);
1120
+ } else {
1121
+ this._focusInput();
1122
+ }
1123
+ }
1124
+ this.dispatchEvent(new Event("close", { bubbles: true }));
1125
+ }
1126
+
1127
+ /** The bound to restore focus to after closing a range popup: the active
1128
+ * endpoint first (it moved after the first selection), then the next
1129
+ * focusable bound.
1130
+ * @returns {"" | "start" | "end"} */
1131
+ _restoreFocusEndpoint() {
1132
+ const active = this._range?.activeEndpoint || "";
1133
+ if (active) {
1134
+ const index = active === "end" ? 1 : 0;
1135
+ const field = this._fields?.[index];
1136
+ if (field && !field.input.disabled && !field.input.readOnly) return active;
1137
+ }
1138
+ return this._resolveRangeEndpoint();
1139
+ }
1140
+ }