@lekoala/date-picker 0.5.1 → 0.5.3

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.
@@ -1,4 +1,5 @@
1
1
  import { autoUpdate, reposition } from "@lekoala/floating";
2
+ import { activationIntents } from "./activation-intents.js";
2
3
  import {
3
4
  getDefaultControlClasses,
4
5
  normalizeControlClasses,
@@ -7,7 +8,7 @@ import {
7
8
  } from "./control-classes.js";
8
9
  import { compareDates, isDate, monthKey, todayISO } from "./date.js";
9
10
  import { DateCalendarElement } from "./date-calendar.js";
10
- import { DateFieldController } from "./date-field.js";
11
+ import { DateFieldController, isEditable } from "./date-field.js";
11
12
  import { DateRangeController, normalizeRange } from "./date-range.js";
12
13
  import { createDateAdapter, formatLongDate, resolveLocale } from "./intl.js";
13
14
  import { getDefaultMessages, setDefaultMessages, subscribeDefaultMessages } from "./messages.js";
@@ -19,6 +20,10 @@ let uid = 0;
19
20
  /** Pointer travel, in CSS pixels, that turns a press into an endpoint drag. */
20
21
  const DRAG_THRESHOLD = 4;
21
22
 
23
+ /** A grid activation intent: the generation at interaction time plus the bound
24
+ * a drag drop targets, empty for a normal pick.
25
+ * @typedef {{id: number, endpoint: "" | "start" | "end"}} PickIntent */
26
+
22
27
  export class DatePickerElement extends HTMLElement {
23
28
  static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
24
29
 
@@ -62,12 +67,10 @@ export class DatePickerElement extends HTMLElement {
62
67
  this._timeOrderOwned = new Set();
63
68
  /** @type {"" | "start" | "end"} */
64
69
  this._lastFocusEndpoint = "";
65
- this._rangeCommitId = 0;
66
- /** Grid activation intents, keyed by date: the generation at interaction
67
- * time (range mode supersession/stale protection) plus the bound a drag
68
- * drop targets, empty for a normal pick.
69
- * @type {Map<string, {id: number, endpoint: "" | "start" | "end"}>} */
70
- this._pendingIntents = new Map();
70
+ /** Activation generation. A grid intent records it at interaction time
71
+ * and travels with its own activation (see `activationIntents`), so a late
72
+ * response is checked against the generation, never looked up by date. */
73
+ this._activationId = 0;
71
74
  /** Candidate date currently projected onto the calendar band, if any. */
72
75
  this._previewDate = "";
73
76
  /** Live endpoint drag, or null. @type {{pointerId:number, endpoint:"start"|"end", x:number, y:number, date:string, active:boolean} | null} */
@@ -84,7 +87,8 @@ export class DatePickerElement extends HTMLElement {
84
87
  this._originalDescribedBy = "";
85
88
  this._rangeOriginalDescribedBy = ["", ""];
86
89
  this._formatHint = null;
87
- this._generatedPlaceholder = false;
90
+ /** Inputs whose placeholder was generated by this element. */
91
+ this._generatedPlaceholders = new WeakSet();
88
92
  this._controller = null;
89
93
  this._stopTracking = null;
90
94
  this._open = false;
@@ -95,13 +99,13 @@ export class DatePickerElement extends HTMLElement {
95
99
  this._controlClasses = null;
96
100
  /** @type {(() => void) | null} */
97
101
  this._stopDefaultMessages = null;
98
- /** @type {any} */
102
+ /** @type {import("./date-calendar.js").DateSource | null} */
99
103
  this._source = null;
100
- /** @type {any} */
104
+ /** @type {import("./date-calendar.js").DateStateResolver | null} */
101
105
  this._dateState = null;
102
- /** @type {any} */
106
+ /** @type {import("./date-calendar.js").DayRenderer | null} */
103
107
  this._renderDay = null;
104
- /** @type {any} */
108
+ /** @type {import("./date-calendar.js").DateDisabledPredicate | null} */
105
109
  this._isDateDisabled = null;
106
110
  /** Public picker coordinate-space request. @type {"viewport" | "document"} */
107
111
  this._coordinateSpace = "viewport";
@@ -111,6 +115,14 @@ export class DatePickerElement extends HTMLElement {
111
115
  return this.hasAttribute("range") && Boolean(this._range);
112
116
  }
113
117
 
118
+ /** A commit result may only be applied while this picker still owns the
119
+ * controller that produced it. Disconnect tears controllers down and
120
+ * reconnect builds new ones, so a previous instance can never write again.
121
+ * @param {DateFieldController} field */
122
+ _ownsField(field) {
123
+ return this._connected && (field === this._field || Boolean(this._fields?.includes(field)));
124
+ }
125
+
114
126
  connectedCallback() {
115
127
  if (this._connected) return;
116
128
  upgradeProperties(this, [
@@ -135,6 +147,9 @@ export class DatePickerElement extends HTMLElement {
135
147
  this._stopDefaultMessages = subscribeDefaultMessages(() => {
136
148
  if (this._messages === null) this._refreshMessages();
137
149
  });
150
+ // Applying the initial value clears custom validity; check it against
151
+ // min/max/isDateDisabled/source like any later constraint change.
152
+ void this.validate();
138
153
  }
139
154
  }
140
155
 
@@ -166,15 +181,11 @@ export class DatePickerElement extends HTMLElement {
166
181
  else input.setCustomValidity(this.messages.invalidDate);
167
182
 
168
183
  // Native form reset restores control defaultValues, then _restoreDefault()
169
- // recommits the canonical value from them. Never redefine an author-provided
170
- // input.defaultValue here: the next reset must use the native default (which
171
- // the app may change). Only when enhancement itself supplies the initial
172
- // text (picker value attr, no input value attr) does that text become the
173
- // default to restore.
184
+ // recommits the canonical value from them. Only when enhancement itself
185
+ // supplies the initial text (picker value attr, no input value attr) does
186
+ // that text become the default to restore.
174
187
  if (this._field.hidden) this._field.hidden.defaultValue = this._value;
175
- if (input.getAttribute("value") == null && input.defaultValue === "") {
176
- input.defaultValue = input.value;
177
- }
188
+ this._field.adoptDefault();
178
189
  }
179
190
 
180
191
  /**
@@ -241,25 +252,29 @@ export class DatePickerElement extends HTMLElement {
241
252
  const adapter = this._adapter();
242
253
  const initialStart = isDate(startInput.value) ? startInput.value : adapter.parse(startInput.value);
243
254
  const initialEnd = isDate(endInput.value) ? endInput.value : adapter.parse(endInput.value);
255
+ // Read the authored text before _setRange() reformats an unparsable one away.
256
+ const startText = startInput.value;
257
+ const endText = endInput.value;
244
258
  this._setRange(initialStart || "", initialEnd || "", { emit: false });
245
- if (startInput.value.trim() && !isDate(initialStart))
246
- startInput.setCustomValidity(this.messages.invalidDate);
247
- if (endInput.value.trim() && !isDate(initialEnd)) endInput.setCustomValidity(this.messages.invalidDate);
259
+ for (const [input, text, initial] of /** @type {const} */ ([
260
+ [startInput, startText, initialStart],
261
+ [endInput, endText, initialEnd],
262
+ ])) {
263
+ if (!text.trim() || isDate(initial)) continue;
264
+ input.value = text;
265
+ input.setCustomValidity(this.messages.invalidDate);
266
+ }
248
267
 
249
268
  if (this._fields[0].hidden) this._fields[0].hidden.defaultValue = this._range.start;
250
269
  if (this._fields[1].hidden) this._fields[1].hidden.defaultValue = this._range.end;
251
- if (startInput.getAttribute("value") == null && startInput.defaultValue === "") {
252
- startInput.defaultValue = startInput.value;
253
- }
254
- if (endInput.getAttribute("value") == null && endInput.defaultValue === "") {
255
- endInput.defaultValue = endInput.value;
256
- }
270
+ for (const field of this._fields) field.adoptDefault();
257
271
  }
258
272
 
259
273
  disconnectedCallback() {
260
274
  this._stopDefaultMessages?.();
261
275
  this._stopDefaultMessages = null;
262
276
  this._connected = false;
277
+ this._suppressFocusOpen = false;
263
278
  this.hide(false);
264
279
  this._controller?.abort();
265
280
  this._controller = null;
@@ -301,11 +316,26 @@ export class DatePickerElement extends HTMLElement {
301
316
 
302
317
  /** @param {string} name @param {string|null} oldValue @param {string|null} newValue */
303
318
  attributeChangedCallback(name, oldValue, newValue) {
304
- if (!this._connected || this._reflecting || oldValue === newValue) return;
319
+ if (this._reflecting || oldValue === newValue) return;
320
+ if (name === "value" && newValue && !isDate(newValue)) {
321
+ this._reflectValue(this._value);
322
+ return;
323
+ }
324
+ if ((name === "min" || name === "max") && newValue && !isDate(newValue)) {
325
+ this.removeAttribute(name);
326
+ if (this._connected) {
327
+ this._syncCalendarOptions();
328
+ void this.validate();
329
+ }
330
+ return;
331
+ }
332
+ if (!this._connected) return;
305
333
  if (name === "value") {
306
334
  if (this._rangeMode()) return;
307
- if (!newValue || isDate(newValue))
335
+ if (!newValue || isDate(newValue)) {
336
+ this._supersedeActivations();
308
337
  this._setValue(newValue || "", { emit: false, format: true, reflect: false });
338
+ }
309
339
  return;
310
340
  }
311
341
  if (name === "locale") this._refreshLocale();
@@ -334,6 +364,7 @@ export class DatePickerElement extends HTMLElement {
334
364
  throw new TypeError("value is not available in range mode; use range");
335
365
  }
336
366
  if (value && !isDate(value)) throw new TypeError(`Invalid date-picker value: ${value}`);
367
+ this._supersedeActivations();
337
368
  this._setValue(value || "", { emit: false, format: true });
338
369
  }
339
370
 
@@ -345,6 +376,7 @@ export class DatePickerElement extends HTMLElement {
345
376
  set range(value) {
346
377
  if (!this._rangeMode()) throw new TypeError("range is only available on <date-picker range>");
347
378
  const { start, end } = normalizeRange(value);
379
+ this._supersedeActivations();
348
380
  this._setRange(start, end);
349
381
  }
350
382
 
@@ -444,7 +476,7 @@ export class DatePickerElement extends HTMLElement {
444
476
  if (this._connected) void this.validate();
445
477
  }
446
478
 
447
- /** @public @returns {any} */
479
+ /** @public @returns {import("./date-calendar.js").DateSource | null} */
448
480
  get source() {
449
481
  return this._source;
450
482
  }
@@ -455,7 +487,7 @@ export class DatePickerElement extends HTMLElement {
455
487
  if (this._connected) void this.validate();
456
488
  }
457
489
 
458
- /** @public @returns {any} */
490
+ /** @public @returns {import("./date-calendar.js").DateStateResolver | null} */
459
491
  get dateState() {
460
492
  return this._dateState;
461
493
  }
@@ -466,7 +498,7 @@ export class DatePickerElement extends HTMLElement {
466
498
  if (this._connected) void this.validate();
467
499
  }
468
500
 
469
- /** @public @returns {any} */
501
+ /** @public @returns {import("./date-calendar.js").DayRenderer | null} */
470
502
  get renderDay() {
471
503
  return this._renderDay;
472
504
  }
@@ -476,7 +508,7 @@ export class DatePickerElement extends HTMLElement {
476
508
  if (this._calendar) this._calendar.renderDay = this._renderDay;
477
509
  }
478
510
 
479
- /** @public @returns {any} */
511
+ /** @public @returns {import("./date-calendar.js").DateDisabledPredicate | null} */
480
512
  get isDateDisabled() {
481
513
  return this._isDateDisabled;
482
514
  }
@@ -511,8 +543,10 @@ export class DatePickerElement extends HTMLElement {
511
543
  // movement lives in the field controller.
512
544
  this._field?.syncInputState();
513
545
  const button = this._buttons[0]?.button;
546
+ // Own state only: a disabled ancestor fieldset already disables the button
547
+ // natively, and copying it would stick once the fieldset is re-enabled.
514
548
  if (button) button.disabled = input.disabled || input.readOnly;
515
- if (this._open && (input.disabled || input.readOnly)) this.hide(false);
549
+ if (this._open && !isEditable(input)) this.hide(false);
516
550
  }
517
551
 
518
552
  _syncRangeFields() {
@@ -527,9 +561,8 @@ export class DatePickerElement extends HTMLElement {
527
561
  }
528
562
  this._syncDragAffordance();
529
563
  const active = this._range?.activeEndpoint === "end" ? 1 : 0;
530
- if (this._open && (this._fields[active]?.input.disabled || this._fields[active]?.input.readOnly)) {
531
- this.hide(false);
532
- }
564
+ const activeInput = this._fields[active]?.input;
565
+ if (this._open && activeInput && !isEditable(activeInput)) this.hide(false);
533
566
  }
534
567
 
535
568
  _setRangeAria() {
@@ -647,7 +680,7 @@ export class DatePickerElement extends HTMLElement {
647
680
  // Reset is cancelable and runs before native values have been restored.
648
681
  this.hide(false);
649
682
  queueMicrotask(() => {
650
- if (!event.defaultPrevented) restore();
683
+ if (!signal.aborted && !event.defaultPrevented) restore();
651
684
  });
652
685
  },
653
686
  { capture: true, signal },
@@ -710,7 +743,7 @@ export class DatePickerElement extends HTMLElement {
710
743
  // open-on-focus, where the popup is already open when Tab reaches the
711
744
  // button; Escape keeps closing it. Defer focus as in the input handler.
712
745
  if (event.detail === 0) {
713
- setTimeout(() => this._calendar?.focusGrid(), 0);
746
+ this._defer(() => this._calendar?.focusGrid());
714
747
  return;
715
748
  }
716
749
  this.hide(false);
@@ -721,17 +754,27 @@ export class DatePickerElement extends HTMLElement {
721
754
  { signal },
722
755
  );
723
756
  button.addEventListener("keydown", (event) => this._onTriggerKeyDown("", event), { signal });
757
+ calendar.addEventListener("click", (event) => this._captureGridIntent(event), { capture: true, signal });
758
+ calendar.addEventListener("keydown", (event) => this._captureGridIntent(event), {
759
+ capture: true,
760
+ signal,
761
+ });
724
762
  calendar.addEventListener(
725
763
  "dateactivate",
726
764
  (event) => {
727
765
  const custom = /** @type {CustomEvent} */ (event);
728
766
  const date = custom.detail?.date;
729
767
  if (!isDate(date)) return;
768
+ const intent = /** @type {PickIntent | undefined} */ (activationIntents.get(custom));
730
769
  // The app listener on <date-picker> runs after this during bubbling;
731
770
  // defer the commit so preventDefault() is actually respected.
732
771
  queueMicrotask(() => {
733
- if (custom.defaultPrevented || !this._connected) return;
734
- if (this._input?.disabled || this._input?.readOnly) return;
772
+ if (signal.aborted || custom.defaultPrevented || !this._connected) return;
773
+ // A grid pick that resolves after the popup closed, after a newer pick
774
+ // or after a programmatic value must not overwrite it. A programmatic
775
+ // calendar.activateDate() carries no intent and still commits.
776
+ if (intent && intent.id !== this._activationId) return;
777
+ if (!this._input || !isEditable(this._input)) return;
735
778
  this._field?.dirty();
736
779
  this._setValue(date, { emit: true, format: true });
737
780
  this.hide(false);
@@ -755,7 +798,16 @@ export class DatePickerElement extends HTMLElement {
755
798
 
756
799
  for (const [index, field] of this._fields.entries()) {
757
800
  const endpoint = index === 1 ? "end" : "start";
758
- field.input.addEventListener("input", () => field.handleInput(), { signal });
801
+ field.input.addEventListener(
802
+ "input",
803
+ () => {
804
+ // handleInput() rewrites the custom validity: any order error is gone.
805
+ field.handleInput();
806
+ this._orderTags.delete(index);
807
+ if (!field.isDirty) this._revalidateRange(endpoint);
808
+ },
809
+ { signal },
810
+ );
759
811
  field.input.addEventListener("change", () => void this._commitFieldText(endpoint), { signal });
760
812
  field.input.addEventListener("blur", () => void this._commitFieldText(endpoint), { signal });
761
813
  field.input.addEventListener(
@@ -805,12 +857,11 @@ export class DatePickerElement extends HTMLElement {
805
857
  const date = custom.detail?.date;
806
858
  if (!isDate(date)) return;
807
859
  queueMicrotask(() => {
808
- if (custom.defaultPrevented || !this._connected || !this._range) return;
809
- const intent = this._pendingIntents.get(date);
860
+ if (signal.aborted || custom.defaultPrevented || !this._connected || !this._range) return;
861
+ const intent = /** @type {PickIntent | undefined} */ (activationIntents.get(custom));
810
862
  // A response is dropped when the active endpoint changed, the popup
811
863
  // closed or a newer activation superseded this one.
812
- if (intent === undefined || intent.id !== this._rangeCommitId) return;
813
- this._pendingIntents.delete(date);
864
+ if (intent === undefined || intent.id !== this._activationId) return;
814
865
  const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
815
866
  // Click, keyboard and drop share this one machine; only the entry
816
867
  // point differs, a drag naming the handle it grabbed.
@@ -863,8 +914,7 @@ export class DatePickerElement extends HTMLElement {
863
914
  const calendar = this._calendar;
864
915
  if (!this._range || !calendar) return;
865
916
  this._range.focus(endpoint);
866
- this._rangeCommitId++;
867
- this._pendingIntents.clear();
917
+ this._activationId++;
868
918
  this._lastFocusEndpoint = endpoint;
869
919
  const target = this._boundCanonical(endpoint) || todayISO();
870
920
  calendar.display = monthKey(target);
@@ -890,7 +940,7 @@ export class DatePickerElement extends HTMLElement {
890
940
  // Keyboard activation of an already-open picker: switch the bound, then
891
941
  // move into the grid. Escape keeps closing it.
892
942
  this._activateRangeEndpoint(endpoint);
893
- setTimeout(() => this._calendar?.focusGrid(), 0);
943
+ this._defer(() => this._calendar?.focusGrid());
894
944
  return;
895
945
  }
896
946
  if (endpoint !== this._range?.activeEndpoint) {
@@ -913,7 +963,7 @@ export class DatePickerElement extends HTMLElement {
913
963
  if (this._open) {
914
964
  if (this._rangeMode() && endpoint) this._activateRangeEndpoint(endpoint);
915
965
  // Chromium drops script-initiated focus changes during keydown dispatch.
916
- setTimeout(() => this._calendar?.focusGrid(), 0);
966
+ this._defer(() => this._calendar?.focusGrid());
917
967
  return;
918
968
  }
919
969
  if (this._rangeMode()) this.show({ endpoint: endpoint || undefined });
@@ -931,7 +981,7 @@ export class DatePickerElement extends HTMLElement {
931
981
  if (this._open) {
932
982
  // Chromium drops script-initiated focus changes made during keydown
933
983
  // dispatch; defer to a macrotask so the grid can take keyboard focus.
934
- setTimeout(() => this._calendar?.focusGrid(), 0);
984
+ this._defer(() => this._calendar?.focusGrid());
935
985
  } else {
936
986
  this.show();
937
987
  }
@@ -950,8 +1000,7 @@ export class DatePickerElement extends HTMLElement {
950
1000
  if (switched) {
951
1001
  // A late calendar activation targeting the previous endpoint must not
952
1002
  // commit after the intent moved to the other bound.
953
- this._rangeCommitId++;
954
- this._pendingIntents.clear();
1003
+ this._activationId++;
955
1004
  }
956
1005
  if (!this.openOnFocus || this._suppressFocusOpen) return;
957
1006
  const related = event.relatedTarget;
@@ -959,30 +1008,49 @@ export class DatePickerElement extends HTMLElement {
959
1008
  if (!this._open) this.show({ moveFocus: false });
960
1009
  }
961
1010
 
962
- /** Record range-mode grid activation intent (click or keyboard) so a stale,
1011
+ /** Record grid activation intent (click or keyboard) so a stale,
963
1012
  * late-confirmed dateactivate can be recognized and dropped. Each new grid
964
1013
  * activation supersedes every earlier pending one.
965
1014
  * @param {Event} event */
966
1015
  _captureGridIntent(event) {
967
- if (!this._rangeMode() || !this._range) return;
1016
+ if (this._rangeMode() ? !this._range : !this._field) return;
968
1017
  if (event.type === "keydown") {
969
1018
  const key = /** @type {KeyboardEvent} */ (event).key;
970
1019
  if (key !== "Enter" && key !== " ") return;
971
1020
  }
1021
+ // The click tail of a drag never activates: it must not supersede the drop.
1022
+ if (event.type === "click" && this._suppressGridClick) return;
972
1023
  if (!(event.target instanceof Element)) return;
973
1024
  const cell = event.target.closest(".dp-day[data-date]");
974
1025
  const date = cell?.getAttribute("data-date") || "";
975
1026
  if (!isDate(date)) return;
976
- // Each activation supersedes every earlier pending one.
977
- this._pendingIntents.clear();
978
- this._pendingIntents.set(date, { id: ++this._rangeCommitId, endpoint: "" });
1027
+ this._attachIntent(date, "");
1028
+ }
1029
+
1030
+ /**
1031
+ * Hand a new intent to the calendar's next activation of `date`. Taking a
1032
+ * new generation supersedes every earlier pending activation.
1033
+ * @param {string} date @param {"" | "start" | "end"} endpoint
1034
+ */
1035
+ _attachIntent(date, endpoint) {
1036
+ if (!this._calendar) return;
1037
+ this._calendar._hostIntent = { date, intent: { id: ++this._activationId, endpoint } };
1038
+ }
1039
+
1040
+ /**
1041
+ * Invalidate every pending grid activation: each one carries its own intent,
1042
+ * which now fails the generation check. A programmatic
1043
+ * `calendar.activateDate()` carries none.
1044
+ */
1045
+ _supersedeActivations() {
1046
+ this._activationId++;
979
1047
  }
980
1048
 
981
1049
  /** Whether one bound can receive a user pick right now.
982
1050
  * @param {"start" | "end"} bound */
983
1051
  _boundEditable(bound) {
984
1052
  const field = this._fields?.[bound === "end" ? 1 : 0];
985
- return field != null && !field.input.disabled && !field.input.readOnly;
1053
+ return field != null && isEditable(field.input);
986
1054
  }
987
1055
 
988
1056
  /**
@@ -1180,8 +1248,7 @@ export class DatePickerElement extends HTMLElement {
1180
1248
  // guard: a late response can never move a bound after the popup closed or
1181
1249
  // after a newer interaction.
1182
1250
  const target = drag.endpoint === "start" ? transition.range.start : transition.range.end;
1183
- this._pendingIntents.clear();
1184
- this._pendingIntents.set(target, { id: ++this._rangeCommitId, endpoint: drag.endpoint });
1251
+ this._attachIntent(target, drag.endpoint);
1185
1252
  void this._calendar?.activateDate(target);
1186
1253
  }
1187
1254
 
@@ -1253,9 +1320,9 @@ export class DatePickerElement extends HTMLElement {
1253
1320
  this._fields[1].setLocale(this.locale);
1254
1321
  hint.textContent = `${this.messages.formatHint}: ${this._fields[0].placeholder}`;
1255
1322
  for (const [index, field] of this._fields.entries()) {
1256
- if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
1323
+ if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholders.has(field.input)) {
1257
1324
  field.input.placeholder = field.placeholder;
1258
- this._generatedPlaceholder = true;
1325
+ this._generatedPlaceholders.add(field.input);
1259
1326
  }
1260
1327
  const canonical = index === 1 ? this._range?.end || "" : this._range?.start || "";
1261
1328
  if (canonical) field.input.value = field.format(canonical);
@@ -1267,9 +1334,9 @@ export class DatePickerElement extends HTMLElement {
1267
1334
  if (!field) return;
1268
1335
  field.setLocale(this.locale);
1269
1336
  hint.textContent = `${this.messages.formatHint}: ${field.placeholder}`;
1270
- if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
1337
+ if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholders.has(field.input)) {
1271
1338
  field.input.placeholder = field.placeholder;
1272
- this._generatedPlaceholder = true;
1339
+ this._generatedPlaceholders.add(field.input);
1273
1340
  }
1274
1341
  if (this._value) field.input.value = field.format(this._value);
1275
1342
  this._refreshButtonLabel();
@@ -1364,6 +1431,9 @@ export class DatePickerElement extends HTMLElement {
1364
1431
  // current value against the latest constraints regardless of dirtiness.
1365
1432
  if (!options.force && !field.isDirty) return false;
1366
1433
  const result = await field.commit(options);
1434
+ if (!this._ownsField(field) || !field.isCommitCurrent(result.commitId)) {
1435
+ return false;
1436
+ }
1367
1437
  if (result.status === "stale" || result.status === "invalid") return false;
1368
1438
  this._setValue(result.value || "", {
1369
1439
  emit,
@@ -1381,6 +1451,7 @@ export class DatePickerElement extends HTMLElement {
1381
1451
  const field = fields[index];
1382
1452
  if (!field?.isDirty) return false;
1383
1453
  const result = await field.commit();
1454
+ if (!this._ownsField(field) || !field.isCommitCurrent(result.commitId)) return false;
1384
1455
  if (result.status === "stale") return false;
1385
1456
  if (result.status === "invalid") {
1386
1457
  this._orderTags.delete(index);
@@ -1403,7 +1474,7 @@ export class DatePickerElement extends HTMLElement {
1403
1474
  if (!field) return false;
1404
1475
  // User initiated picks must respect disabled/readonly bounds; reset,
1405
1476
  // explicit validation and programmatic assignments keep their own path.
1406
- if (options.user === true && (field.input.disabled || field.input.readOnly)) return false;
1477
+ if (options.user === true && !isEditable(field.input)) return false;
1407
1478
  const next = value || "";
1408
1479
  // The range model may already hold the target (a calendar activation moved
1409
1480
  // it), so change detection reads the field canonical, not the model.
@@ -1509,8 +1580,8 @@ export class DatePickerElement extends HTMLElement {
1509
1580
  /** @type {["start", "end"]} */
1510
1581
  const bounds = ["start", "end"];
1511
1582
  const inputs = [startInput, endInput];
1512
- const startValue = startInput && !startInput.disabled ? startInput.value.trim() : "";
1513
- const endValue = endInput && !endInput.disabled ? endInput.value.trim() : "";
1583
+ const startValue = startInput && !startInput.matches(":disabled") ? startInput.value.trim() : "";
1584
+ const endValue = endInput && !endInput.matches(":disabled") ? endInput.value.trim() : "";
1514
1585
  const inverted =
1515
1586
  Boolean(startInput && endInput && startValue && endValue) &&
1516
1587
  isTime(startValue) &&
@@ -1536,6 +1607,17 @@ export class DatePickerElement extends HTMLElement {
1536
1607
  this._timeOrderOwned.clear();
1537
1608
  }
1538
1609
 
1610
+ /** Run a timer only while the connection that scheduled it remains active.
1611
+ * @param {() => void} callback */
1612
+ _defer(callback) {
1613
+ const controller = this._controller;
1614
+ setTimeout(() => {
1615
+ if (!this._connected || !controller || controller.signal.aborted || this._controller !== controller)
1616
+ return;
1617
+ callback();
1618
+ }, 0);
1619
+ }
1620
+
1539
1621
  _restoreDefault() {
1540
1622
  const field = this._field;
1541
1623
  if (!field) return;
@@ -1572,10 +1654,10 @@ export class DatePickerElement extends HTMLElement {
1572
1654
  const input = this._fields?.[index]?.input;
1573
1655
  if (!input) return;
1574
1656
  this._suppressFocusOpen = true;
1575
- setTimeout(() => {
1657
+ this._defer(() => {
1576
1658
  input.focus();
1577
1659
  this._suppressFocusOpen = false;
1578
- }, 0);
1660
+ });
1579
1661
  }
1580
1662
 
1581
1663
  /**
@@ -1589,10 +1671,10 @@ export class DatePickerElement extends HTMLElement {
1589
1671
  this._returnFocus = null;
1590
1672
  if (!(target instanceof HTMLElement) || !target.isConnected || target.matches(":disabled")) return;
1591
1673
  this._suppressFocusOpen = true;
1592
- setTimeout(() => {
1674
+ this._defer(() => {
1593
1675
  target.focus();
1594
1676
  this._suppressFocusOpen = false;
1595
- }, 0);
1677
+ });
1596
1678
  }
1597
1679
 
1598
1680
  /** @returns {"" | "start" | "end"} */
@@ -1600,12 +1682,12 @@ export class DatePickerElement extends HTMLElement {
1600
1682
  if (!this._fields) return "";
1601
1683
  const preferred = this._lastFocusEndpoint || "start";
1602
1684
  const preferredIndex = preferred === "end" ? 1 : 0;
1603
- if (!this._fields[preferredIndex].input.disabled && !this._fields[preferredIndex].input.readOnly) {
1685
+ if (isEditable(this._fields[preferredIndex].input)) {
1604
1686
  return preferred;
1605
1687
  }
1606
1688
  const alternative = preferred === "end" ? "start" : "end";
1607
1689
  const alternativeIndex = alternative === "end" ? 1 : 0;
1608
- if (!this._fields[alternativeIndex].input.disabled && !this._fields[alternativeIndex].input.readOnly) {
1690
+ if (isEditable(this._fields[alternativeIndex].input)) {
1609
1691
  return alternative;
1610
1692
  }
1611
1693
  return "";
@@ -1621,6 +1703,9 @@ export class DatePickerElement extends HTMLElement {
1621
1703
  // Empty text flows through commit()'s "clear" outcome, not an early exit.
1622
1704
  for (const [index, field] of fields.entries()) {
1623
1705
  const result = await field.commit({ moveDisplay: false });
1706
+ if (!this._ownsField(field) || !field.isCommitCurrent(result.commitId)) continue;
1707
+ // A parse/source error replaced the order error, which no longer owns it.
1708
+ if (result.status === "invalid") this._orderTags.delete(index);
1624
1709
  if (result.status === "ok" || result.status === "clear") {
1625
1710
  const which = index === 1 ? "end" : "start";
1626
1711
  this._setBound(which, result.value || "", { format: result.status === "ok" });
@@ -1631,12 +1716,22 @@ export class DatePickerElement extends HTMLElement {
1631
1716
  return fields.every((field) => field.input.checkValidity());
1632
1717
  }
1633
1718
  const input = this._field?.input;
1634
- if (!input) return true;
1635
- if (!input.value.trim()) {
1636
- input.setCustomValidity("");
1637
- } else {
1638
- await this._commitText(false, { force: true, moveDisplay: false });
1719
+ if (!input) {
1720
+ const dateInputs = this.hasAttribute("range")
1721
+ ? this.querySelectorAll(":scope > input[data-range-start], :scope > input[data-range-end]")
1722
+ : this._input
1723
+ ? [this._input]
1724
+ : [];
1725
+ const timeInputs = this.querySelectorAll(
1726
+ ":scope > input[type=time][data-time-start], :scope > input[type=time][data-time-end]",
1727
+ );
1728
+ return [...dateInputs, ...timeInputs].every(
1729
+ (candidate) => candidate instanceof HTMLInputElement && candidate.checkValidity(),
1730
+ );
1639
1731
  }
1732
+ // Empty text flows through commit()'s "clear" outcome so the canonical
1733
+ // value and hidden ISO are cleared with it.
1734
+ await this._commitText(false, { force: true, moveDisplay: false });
1640
1735
  // Same start-then-end order as the range path, so a from/to inversion
1641
1736
  // ends reported on "end". A programmatic time change resyncs here too.
1642
1737
  this._revalidateTimeOrder("start");
@@ -1656,7 +1751,7 @@ export class DatePickerElement extends HTMLElement {
1656
1751
  this._activateRangeEndpoint(endpoint);
1657
1752
  } else {
1658
1753
  const input = this._input;
1659
- if (!input || input.disabled || input.readOnly) return;
1754
+ if (!input || !isEditable(input)) return;
1660
1755
  const target = this._value || this._adapter().parse(input.value) || todayISO();
1661
1756
  calendar.display = monthKey(target);
1662
1757
  calendar.focusedDate = target;
@@ -1680,7 +1775,14 @@ export class DatePickerElement extends HTMLElement {
1680
1775
  });
1681
1776
  position();
1682
1777
  this._stopTracking = autoUpdate(this, panel, position);
1683
- if (options.moveFocus !== false) queueMicrotask(() => calendar.focusGrid());
1778
+ if (options.moveFocus !== false) {
1779
+ const controller = this._controller;
1780
+ queueMicrotask(() => {
1781
+ if (!this._connected || !controller || controller.signal.aborted || this._controller !== controller)
1782
+ return;
1783
+ calendar.focusGrid();
1784
+ });
1785
+ }
1684
1786
  this.dispatchEvent(new Event("open", { bubbles: true }));
1685
1787
  }
1686
1788
 
@@ -1704,8 +1806,7 @@ export class DatePickerElement extends HTMLElement {
1704
1806
  }
1705
1807
  this._open = false;
1706
1808
  this._setExpanded(false);
1707
- this._rangeCommitId++;
1708
- this._pendingIntents.clear();
1809
+ this._supersedeActivations();
1709
1810
  this._suppressGridClick = false;
1710
1811
  if (this._drag) {
1711
1812
  this._releaseDragCapture(this._drag.pointerId);
@@ -1734,7 +1835,7 @@ export class DatePickerElement extends HTMLElement {
1734
1835
  if (active) {
1735
1836
  const index = active === "end" ? 1 : 0;
1736
1837
  const field = this._fields?.[index];
1737
- if (field && !field.input.disabled && !field.input.readOnly) return active;
1838
+ if (field && isEditable(field.input)) return active;
1738
1839
  }
1739
1840
  return this._resolveRangeEndpoint();
1740
1841
  }
package/src/date.js CHANGED
@@ -42,8 +42,11 @@ export function monthKey(value) {
42
42
  const text = String(value || "");
43
43
  const monthMatch = MONTH_RE.exec(text);
44
44
  if (monthMatch) {
45
+ const year = Number(monthMatch[1]);
45
46
  const month = Number(monthMatch[2]);
46
- if (month >= 1 && month <= 12) return `${monthMatch[1]}-${String(month).padStart(2, "0")}`;
47
+ if (year >= 1 && year <= 9999 && month >= 1 && month <= 12) {
48
+ return `${monthMatch[1]}-${String(month).padStart(2, "0")}`;
49
+ }
47
50
  }
48
51
  const parsed = parseDate(text);
49
52
  if (!parsed) throw new TypeError(`Invalid civil date/month: ${value}`);