@lekoala/date-picker 0.5.2 → 0.6.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.
@@ -12,17 +12,14 @@ import { DateFieldController, isEditable } from "./date-field.js";
12
12
  import { DateRangeController, normalizeRange } from "./date-range.js";
13
13
  import { createDateAdapter, formatLongDate, resolveLocale } from "./intl.js";
14
14
  import { getDefaultMessages, setDefaultMessages, subscribeDefaultMessages } from "./messages.js";
15
+ import { createSession, guard } from "./session.js";
15
16
  import { compareTimes, isTime } from "./time.js";
16
17
  import { upgradeProperties } from "./upgrade-properties.js";
17
18
 
18
19
  let uid = 0;
19
20
 
20
- /** Pointer travel, in CSS pixels, that turns a press into an endpoint drag. */
21
- const DRAG_THRESHOLD = 4;
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 */
21
+ /** A grid activation intent: the generation at interaction time.
22
+ * @typedef {{id: number}} PickIntent */
26
23
 
27
24
  export class DatePickerElement extends HTMLElement {
28
25
  static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
@@ -73,10 +70,6 @@ export class DatePickerElement extends HTMLElement {
73
70
  this._activationId = 0;
74
71
  /** Candidate date currently projected onto the calendar band, if any. */
75
72
  this._previewDate = "";
76
- /** Live endpoint drag, or null. @type {{pointerId:number, endpoint:"start"|"end", x:number, y:number, date:string, active:boolean} | null} */
77
- this._drag = null;
78
- /** A real drag already committed; swallow the click that follows it. */
79
- this._suppressGridClick = false;
80
73
  /** Overlaid calendar triggers, one per field (single) or per bound (range). @type {{button: HTMLButtonElement, endpoint: "" | "start" | "end"}[]} */
81
74
  this._buttons = [];
82
75
  /** Control that opened the popover, so single mode can restore focus to it.
@@ -87,8 +80,10 @@ export class DatePickerElement extends HTMLElement {
87
80
  this._originalDescribedBy = "";
88
81
  this._rangeOriginalDescribedBy = ["", ""];
89
82
  this._formatHint = null;
90
- this._generatedPlaceholder = false;
91
- this._controller = null;
83
+ /** Inputs whose placeholder was generated by this element. */
84
+ this._generatedPlaceholders = new WeakSet();
85
+ /** Connection lifetime token. Captured by every async operation. @type {AbortController | null} */
86
+ this._session = null;
92
87
  this._stopTracking = null;
93
88
  this._open = false;
94
89
  this._suppressFocusOpen = false;
@@ -116,6 +111,7 @@ export class DatePickerElement extends HTMLElement {
116
111
 
117
112
  connectedCallback() {
118
113
  if (this._connected) return;
114
+ this._session = createSession();
119
115
  upgradeProperties(this, [
120
116
  "locale",
121
117
  "min",
@@ -130,17 +126,34 @@ export class DatePickerElement extends HTMLElement {
130
126
  "renderDay",
131
127
  "isDateDisabled",
132
128
  ]);
129
+ this._normalizeAttributes();
133
130
  if (this.hasAttribute("range")) this._connectRange();
134
131
  else this._connectSingle();
132
+ // A refused structure (missing input) must not leave a live session behind:
133
+ // _connected stays the readiness gate, the session is only the token.
134
+ if (!this._connected) {
135
+ this._session.abort();
136
+ this._session = null;
137
+ return;
138
+ }
135
139
  // The range setter requires the controller created by _connectRange().
136
- if (this._connected) {
137
- upgradeProperties(this, ["value", "range"]);
138
- this._stopDefaultMessages = subscribeDefaultMessages(() => {
139
- if (this._messages === null) this._refreshMessages();
140
- });
141
- // Applying the initial value clears custom validity; check it against
142
- // min/max/isDateDisabled/source like any later constraint change.
143
- void this.validate();
140
+ upgradeProperties(this, ["value", "range"]);
141
+ this._stopDefaultMessages = subscribeDefaultMessages(() => {
142
+ if (this._messages === null) this._refreshMessages();
143
+ });
144
+ // Applying the initial value clears custom validity; check it against
145
+ // min/max/isDateDisabled/source like any later constraint change.
146
+ void this.validate();
147
+ }
148
+
149
+ /** Establish the canonical connect-time state: invalid constraints are
150
+ * dropped here, not rewritten while the element is detached. */
151
+ _normalizeAttributes() {
152
+ const value = this.getAttribute("value");
153
+ if (value && !isDate(value)) this.removeAttribute("value");
154
+ for (const name of ["min", "max"]) {
155
+ const raw = this.getAttribute(name);
156
+ if (raw && !isDate(raw)) this.removeAttribute(name);
144
157
  }
145
158
  }
146
159
 
@@ -265,9 +278,10 @@ export class DatePickerElement extends HTMLElement {
265
278
  this._stopDefaultMessages?.();
266
279
  this._stopDefaultMessages = null;
267
280
  this._connected = false;
281
+ this._suppressFocusOpen = false;
268
282
  this.hide(false);
269
- this._controller?.abort();
270
- this._controller = null;
283
+ this._session?.abort();
284
+ this._session = null;
271
285
  this._clearTimeOrderValidity();
272
286
  this._timeFields = [null, null];
273
287
  if (this._rangeMode()) {
@@ -306,7 +320,22 @@ export class DatePickerElement extends HTMLElement {
306
320
 
307
321
  /** @param {string} name @param {string|null} oldValue @param {string|null} newValue */
308
322
  attributeChangedCallback(name, oldValue, newValue) {
309
- if (!this._connected || this._reflecting || oldValue === newValue) return;
323
+ if (this._reflecting || oldValue === newValue) return;
324
+ // Attributes are prepared offline and normalized at connect: no reflection,
325
+ // constrained sync or validation runs on a detached element.
326
+ if (!this._connected) return;
327
+ if (name === "value" && newValue && !isDate(newValue)) {
328
+ this._reflectValue(this._value);
329
+ return;
330
+ }
331
+ if ((name === "min" || name === "max") && newValue && !isDate(newValue)) {
332
+ this.removeAttribute(name);
333
+ if (this._connected) {
334
+ this._syncCalendarOptions();
335
+ void this.validate();
336
+ }
337
+ return;
338
+ }
310
339
  if (name === "value") {
311
340
  if (this._rangeMode()) return;
312
341
  if (!newValue || isDate(newValue)) {
@@ -536,7 +565,6 @@ export class DatePickerElement extends HTMLElement {
536
565
  const field = this._fields[index];
537
566
  button.disabled = Boolean(field?.input.disabled || field?.input.readOnly);
538
567
  }
539
- this._syncDragAffordance();
540
568
  const active = this._range?.activeEndpoint === "end" ? 1 : 0;
541
569
  const activeInput = this._fields[active]?.input;
542
570
  if (this._open && activeInput && !isEditable(activeInput)) this.hide(false);
@@ -656,9 +684,11 @@ export class DatePickerElement extends HTMLElement {
656
684
  if (!(event.target instanceof HTMLFormElement) || !input || event.target !== input.form) return;
657
685
  // Reset is cancelable and runs before native values have been restored.
658
686
  this.hide(false);
659
- queueMicrotask(() => {
660
- if (!event.defaultPrevented) restore();
661
- });
687
+ queueMicrotask(
688
+ guard(signal, () => {
689
+ if (!event.defaultPrevented) restore();
690
+ }),
691
+ );
662
692
  },
663
693
  { capture: true, signal },
664
694
  );
@@ -670,9 +700,9 @@ export class DatePickerElement extends HTMLElement {
670
700
  const button = this._buttons[0]?.button;
671
701
  const calendar = this._calendar;
672
702
  if (!input || !button || !calendar) return;
673
- const controller = new AbortController();
674
- this._controller = controller;
675
- const { signal } = controller;
703
+ const session = this._session;
704
+ if (!session) return;
705
+ const { signal } = session;
676
706
 
677
707
  input.addEventListener(
678
708
  "input",
@@ -720,7 +750,7 @@ export class DatePickerElement extends HTMLElement {
720
750
  // open-on-focus, where the popup is already open when Tab reaches the
721
751
  // button; Escape keeps closing it. Defer focus as in the input handler.
722
752
  if (event.detail === 0) {
723
- setTimeout(() => this._calendar?.focusGrid(), 0);
753
+ this._defer(() => this._calendar?.focusGrid());
724
754
  return;
725
755
  }
726
756
  this.hide(false);
@@ -746,7 +776,7 @@ export class DatePickerElement extends HTMLElement {
746
776
  // The app listener on <date-picker> runs after this during bubbling;
747
777
  // defer the commit so preventDefault() is actually respected.
748
778
  queueMicrotask(() => {
749
- if (custom.defaultPrevented || !this._connected) return;
779
+ if (signal.aborted || custom.defaultPrevented) return;
750
780
  // A grid pick that resolves after the popup closed, after a newer pick
751
781
  // or after a programmatic value must not overwrite it. A programmatic
752
782
  // calendar.activateDate() carries no intent and still commits.
@@ -769,9 +799,9 @@ export class DatePickerElement extends HTMLElement {
769
799
  _bindRange() {
770
800
  const calendar = this._calendar;
771
801
  if (!this._fields || !this._buttons.length || !calendar) return;
772
- const controller = new AbortController();
773
- this._controller = controller;
774
- const { signal } = controller;
802
+ const session = this._session;
803
+ if (!session) return;
804
+ const { signal } = session;
775
805
 
776
806
  for (const [index, field] of this._fields.entries()) {
777
807
  const endpoint = index === 1 ? "end" : "start";
@@ -811,14 +841,6 @@ export class DatePickerElement extends HTMLElement {
811
841
  calendar.addEventListener(
812
842
  "click",
813
843
  (event) => {
814
- if (this._suppressGridClick) {
815
- // The gesture already committed through the drop path; this click is
816
- // only the tail of the drag and must not activate a second time.
817
- this._suppressGridClick = false;
818
- event.preventDefault();
819
- event.stopImmediatePropagation();
820
- return;
821
- }
822
844
  this._captureGridIntent(event);
823
845
  },
824
846
  { capture: true, signal },
@@ -834,17 +856,14 @@ export class DatePickerElement extends HTMLElement {
834
856
  const date = custom.detail?.date;
835
857
  if (!isDate(date)) return;
836
858
  queueMicrotask(() => {
837
- if (custom.defaultPrevented || !this._connected || !this._range) return;
859
+ if (signal.aborted || custom.defaultPrevented || !this._range) return;
838
860
  const intent = /** @type {PickIntent | undefined} */ (activationIntents.get(custom));
839
861
  // A response is dropped when the active endpoint changed, the popup
840
862
  // closed or a newer activation superseded this one.
841
863
  if (intent === undefined || intent.id !== this._activationId) return;
842
864
  const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
843
- // Click, keyboard and drop share this one machine; only the entry
844
- // point differs, a drag naming the handle it grabbed.
845
- const result = intent.endpoint
846
- ? this._range.moveEndpoint(date, intent.endpoint, editable)
847
- : this._range.activate(date, editable);
865
+ // Click and keyboard share this one machine.
866
+ const result = this._range.activate(date, editable);
848
867
  if (result.status === "refused") {
849
868
  this._calendar?.dispatchEvent(
850
869
  new CustomEvent("dateinvalid", {
@@ -855,9 +874,6 @@ export class DatePickerElement extends HTMLElement {
855
874
  return;
856
875
  }
857
876
  this._applyRangeTransition(result);
858
- // A drop adjusts an existing range rather than validating it, so the
859
- // popup stays open for the next adjustment.
860
- if (intent.endpoint) return;
861
877
  if (result.close || result.status === "complete") {
862
878
  this.hide(false);
863
879
  this._focusField(result.endpoint);
@@ -871,11 +887,6 @@ export class DatePickerElement extends HTMLElement {
871
887
  calendar.addEventListener("datefocus", (event) => this._onGridDateFocus(event), { signal });
872
888
  calendar.addEventListener("pointerover", (event) => this._onGridPointerOver(event), { signal });
873
889
  calendar.addEventListener("pointerleave", () => this._onGridPointerLeave(), { signal });
874
- calendar.addEventListener("pointerdown", (event) => this._onGridPointerDown(event), { signal });
875
- calendar.addEventListener("pointermove", (event) => this._onGridPointerMove(event), { signal });
876
- calendar.addEventListener("pointerup", (event) => this._onGridPointerUp(event), { signal });
877
- calendar.addEventListener("pointercancel", (event) => this._onGridPointerCancel(event), { signal });
878
- calendar.addEventListener("lostpointercapture", (event) => this._onGridPointerCancel(event), { signal });
879
890
  // Re-check availability after a source load. validate() commits with
880
891
  // moveDisplay:false, so the visible month stays where the user navigated.
881
892
  calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
@@ -917,7 +928,7 @@ export class DatePickerElement extends HTMLElement {
917
928
  // Keyboard activation of an already-open picker: switch the bound, then
918
929
  // move into the grid. Escape keeps closing it.
919
930
  this._activateRangeEndpoint(endpoint);
920
- setTimeout(() => this._calendar?.focusGrid(), 0);
931
+ this._defer(() => this._calendar?.focusGrid());
921
932
  return;
922
933
  }
923
934
  if (endpoint !== this._range?.activeEndpoint) {
@@ -940,7 +951,7 @@ export class DatePickerElement extends HTMLElement {
940
951
  if (this._open) {
941
952
  if (this._rangeMode() && endpoint) this._activateRangeEndpoint(endpoint);
942
953
  // Chromium drops script-initiated focus changes during keydown dispatch.
943
- setTimeout(() => this._calendar?.focusGrid(), 0);
954
+ this._defer(() => this._calendar?.focusGrid());
944
955
  return;
945
956
  }
946
957
  if (this._rangeMode()) this.show({ endpoint: endpoint || undefined });
@@ -958,7 +969,7 @@ export class DatePickerElement extends HTMLElement {
958
969
  if (this._open) {
959
970
  // Chromium drops script-initiated focus changes made during keydown
960
971
  // dispatch; defer to a macrotask so the grid can take keyboard focus.
961
- setTimeout(() => this._calendar?.focusGrid(), 0);
972
+ this._defer(() => this._calendar?.focusGrid());
962
973
  } else {
963
974
  this.show();
964
975
  }
@@ -995,23 +1006,21 @@ export class DatePickerElement extends HTMLElement {
995
1006
  const key = /** @type {KeyboardEvent} */ (event).key;
996
1007
  if (key !== "Enter" && key !== " ") return;
997
1008
  }
998
- // The click tail of a drag never activates: it must not supersede the drop.
999
- if (event.type === "click" && this._suppressGridClick) return;
1000
1009
  if (!(event.target instanceof Element)) return;
1001
1010
  const cell = event.target.closest(".dp-day[data-date]");
1002
1011
  const date = cell?.getAttribute("data-date") || "";
1003
1012
  if (!isDate(date)) return;
1004
- this._attachIntent(date, "");
1013
+ this._attachIntent(date);
1005
1014
  }
1006
1015
 
1007
1016
  /**
1008
1017
  * Hand a new intent to the calendar's next activation of `date`. Taking a
1009
1018
  * new generation supersedes every earlier pending activation.
1010
- * @param {string} date @param {"" | "start" | "end"} endpoint
1019
+ * @param {string} date
1011
1020
  */
1012
- _attachIntent(date, endpoint) {
1021
+ _attachIntent(date) {
1013
1022
  if (!this._calendar) return;
1014
- this._calendar._hostIntent = { date, intent: { id: ++this._activationId, endpoint } };
1023
+ this._calendar._hostIntent = { date, intent: { id: ++this._activationId } };
1015
1024
  }
1016
1025
 
1017
1026
  /**
@@ -1066,16 +1075,16 @@ export class DatePickerElement extends HTMLElement {
1066
1075
  * `rangechange`, no field write, no validation and no source request — the
1067
1076
  * projection reads the month state already loaded, and the real availability
1068
1077
  * check still runs on commit.
1069
- * @param {string} date @param {"" | "start" | "end"} [endpoint]
1078
+ * @param {string} date
1070
1079
  */
1071
- _setPreview(date, endpoint = "") {
1080
+ _setPreview(date) {
1072
1081
  const calendar = this._calendar;
1073
1082
  if (!calendar || !this._range || !this._open) return;
1074
1083
  // A cell already known to be unavailable must not promise a band that the
1075
1084
  // commit would refuse.
1076
1085
  const candidate = isDate(date) && !calendar.getDateState(date).disabled ? date : "";
1077
1086
  const preview = candidate
1078
- ? this._range.previewRange(candidate, endpoint, (bound) => this._boundEditable(bound))
1087
+ ? this._range.previewRange(candidate, (bound) => this._boundEditable(bound))
1079
1088
  : null;
1080
1089
  if (!preview) {
1081
1090
  this._clearPreview();
@@ -1096,7 +1105,6 @@ export class DatePickerElement extends HTMLElement {
1096
1105
  _onGridDateFocus(event) {
1097
1106
  // The keyboard candidate is the cell that actually took focus, so Enter
1098
1107
  // commits exactly what the roving focus was previewing.
1099
- if (this._drag) return;
1100
1108
  const date = /** @type {CustomEvent} */ (event).detail?.date;
1101
1109
  if (isDate(date)) this._setPreview(date);
1102
1110
  }
@@ -1108,15 +1116,11 @@ export class DatePickerElement extends HTMLElement {
1108
1116
  */
1109
1117
  _onGridPointerOver(event) {
1110
1118
  const pointer = /** @type {PointerEvent} */ (event);
1111
- if (this._drag || pointer.pointerType === "touch") return;
1119
+ if (pointer.pointerType === "touch") return;
1112
1120
  this._setPreview(this._cellDate(pointer.target));
1113
1121
  }
1114
1122
 
1115
1123
  _onGridPointerLeave() {
1116
- // Before capture, a gesture that leaves the grid can no longer become a
1117
- // drag; drop it so it cannot block the next press.
1118
- if (this._drag?.active === false) this._drag = null;
1119
- if (this._drag) return;
1120
1124
  this._clearPreview();
1121
1125
  }
1122
1126
 
@@ -1127,141 +1131,6 @@ export class DatePickerElement extends HTMLElement {
1127
1131
  return isDate(date) ? date : "";
1128
1132
  }
1129
1133
 
1130
- /** @param {number} x @param {number} y @returns {string} */
1131
- _dateAtPoint(x, y) {
1132
- const element = this.ownerDocument.elementFromPoint(x, y);
1133
- if (!(element instanceof Element) || !this._calendar?.contains(element)) return "";
1134
- return this._cellDate(element);
1135
- }
1136
-
1137
- /**
1138
- * Arm an endpoint drag. Only a complete, ordered range offers two distinct
1139
- * handles; a one-day range carries both markers on the same cell, and
1140
- * guessing which one the user meant would be worse than not dragging.
1141
- * @param {Event} event
1142
- */
1143
- _onGridPointerDown(event) {
1144
- const pointer = /** @type {PointerEvent} */ (event);
1145
- this._suppressGridClick = false;
1146
- // An armed gesture that never started and never released (the pointer left
1147
- // the grid) must not block the next one.
1148
- if (this._drag?.active === false) this._drag = null;
1149
- if (!this._open || !this._range || this._drag) return;
1150
- if (!pointer.isPrimary || pointer.button !== 0) return;
1151
- // Mouse and pen first: touch keeps native scrolling (no global
1152
- // `touch-action: none`) and stays a plain tap.
1153
- if (pointer.pointerType !== "mouse" && pointer.pointerType !== "pen") return;
1154
- if (!this._range.complete) return;
1155
- const cell = pointer.target instanceof Element ? pointer.target.closest(".dp-day[data-date]") : null;
1156
- if (!(cell instanceof HTMLElement)) return;
1157
- const isStart = cell.hasAttribute("data-range-start");
1158
- const isEnd = cell.hasAttribute("data-range-end");
1159
- if (isStart === isEnd) return;
1160
- const endpoint = isStart ? "start" : "end";
1161
- const date = this._cellDate(cell);
1162
- if (!date || !this._boundEditable(endpoint)) return;
1163
- this._drag = {
1164
- pointerId: pointer.pointerId,
1165
- endpoint,
1166
- x: pointer.clientX,
1167
- y: pointer.clientY,
1168
- date,
1169
- active: false,
1170
- };
1171
- }
1172
-
1173
- /** @param {Event} event */
1174
- _onGridPointerMove(event) {
1175
- const pointer = /** @type {PointerEvent} */ (event);
1176
- const drag = this._drag;
1177
- if (!drag || pointer.pointerId !== drag.pointerId) return;
1178
- if (!drag.active) {
1179
- // A press and release without travel stays a plain click.
1180
- const travelled =
1181
- Math.abs(pointer.clientX - drag.x) > DRAG_THRESHOLD ||
1182
- Math.abs(pointer.clientY - drag.y) > DRAG_THRESHOLD;
1183
- if (!travelled) return;
1184
- drag.active = true;
1185
- // Capture only once the gesture is a drag. Capturing on the press would
1186
- // retarget the trailing click to the calendar and swallow a plain pick
1187
- // on the very cell that carries a handle.
1188
- try {
1189
- this._calendar?.setPointerCapture(drag.pointerId);
1190
- } catch {
1191
- // No active pointer for this id (synthetic event): move and up still
1192
- // arrive through normal bubbling.
1193
- }
1194
- this._syncDragAffordance();
1195
- }
1196
- const date = this._dateAtPoint(pointer.clientX, pointer.clientY);
1197
- // An unavailable cell is not a drop target: hold the last valid projection
1198
- // instead of promising a move that would be refused.
1199
- if (!date || this._calendar?.getDateState(date).disabled) return;
1200
- drag.date = date;
1201
- this._setPreview(date, drag.endpoint);
1202
- }
1203
-
1204
- /** @param {Event} event */
1205
- _onGridPointerUp(event) {
1206
- const pointer = /** @type {PointerEvent} */ (event);
1207
- const drag = this._drag;
1208
- if (!drag || pointer.pointerId !== drag.pointerId) return;
1209
- this._drag = null;
1210
- this._releaseDragCapture(drag.pointerId);
1211
- if (!drag.active) {
1212
- this._syncDragAffordance();
1213
- return;
1214
- }
1215
- this._suppressGridClick = true;
1216
- const transition = this._range?.projectEndpoint(drag.date, drag.endpoint, (bound) =>
1217
- this._boundEditable(bound),
1218
- );
1219
- if (!transition || transition.status === "refused" || !transition.changedEndpoints.length) {
1220
- this._syncHighlight();
1221
- return;
1222
- }
1223
- // The drop goes through the same availability check and the same
1224
- // cancelable `dateactivate` seam as a click, under the same generation
1225
- // guard: a late response can never move a bound after the popup closed or
1226
- // after a newer interaction.
1227
- const target = drag.endpoint === "start" ? transition.range.start : transition.range.end;
1228
- this._attachIntent(target, drag.endpoint);
1229
- void this._calendar?.activateDate(target);
1230
- }
1231
-
1232
- /** @param {Event} event */
1233
- _onGridPointerCancel(event) {
1234
- const pointer = /** @type {PointerEvent} */ (event);
1235
- const drag = this._drag;
1236
- if (!drag || pointer.pointerId !== drag.pointerId) return;
1237
- this._drag = null;
1238
- this._releaseDragCapture(drag.pointerId);
1239
- // A cancelled gesture commits nothing and restores the committed band.
1240
- this._syncHighlight();
1241
- }
1242
-
1243
- /** @param {number} pointerId */
1244
- _releaseDragCapture(pointerId) {
1245
- const calendar = this._calendar;
1246
- if (calendar?.hasPointerCapture(pointerId)) calendar.releasePointerCapture(pointerId);
1247
- }
1248
-
1249
- /** Expose whether the band currently offers draggable handles, and whether
1250
- * one is being dragged, so the cursor can say so. */
1251
- _syncDragAffordance() {
1252
- const calendar = this._calendar;
1253
- if (!calendar || !this._range) return;
1254
- if (this._drag?.active) {
1255
- calendar.setAttribute("data-range-drag", "active");
1256
- return;
1257
- }
1258
- const { start, end } = this._range;
1259
- const draggable =
1260
- this._range.complete && start !== end && (this._boundEditable("start") || this._boundEditable("end"));
1261
- if (draggable) calendar.setAttribute("data-range-drag", "ready");
1262
- else calendar.removeAttribute("data-range-drag");
1263
- }
1264
-
1265
1134
  /** @param {KeyboardEvent} event */
1266
1135
  _onEscape(event) {
1267
1136
  if (event.key === "Escape" && this._open) {
@@ -1297,9 +1166,9 @@ export class DatePickerElement extends HTMLElement {
1297
1166
  this._fields[1].setLocale(this.locale);
1298
1167
  hint.textContent = `${this.messages.formatHint}: ${this._fields[0].placeholder}`;
1299
1168
  for (const [index, field] of this._fields.entries()) {
1300
- if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
1169
+ if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholders.has(field.input)) {
1301
1170
  field.input.placeholder = field.placeholder;
1302
- this._generatedPlaceholder = true;
1171
+ this._generatedPlaceholders.add(field.input);
1303
1172
  }
1304
1173
  const canonical = index === 1 ? this._range?.end || "" : this._range?.start || "";
1305
1174
  if (canonical) field.input.value = field.format(canonical);
@@ -1311,9 +1180,9 @@ export class DatePickerElement extends HTMLElement {
1311
1180
  if (!field) return;
1312
1181
  field.setLocale(this.locale);
1313
1182
  hint.textContent = `${this.messages.formatHint}: ${field.placeholder}`;
1314
- if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
1183
+ if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholders.has(field.input)) {
1315
1184
  field.input.placeholder = field.placeholder;
1316
- this._generatedPlaceholder = true;
1185
+ this._generatedPlaceholders.add(field.input);
1317
1186
  }
1318
1187
  if (this._value) field.input.value = field.format(this._value);
1319
1188
  this._refreshButtonLabel();
@@ -1403,11 +1272,15 @@ export class DatePickerElement extends HTMLElement {
1403
1272
  /** @param {boolean} emit @param {{force?: boolean, moveDisplay?: boolean}} [options] */
1404
1273
  async _commitText(emit, options = {}) {
1405
1274
  const field = this._field;
1406
- if (!field) return false;
1275
+ const session = this._session;
1276
+ if (!field || !session) return false;
1407
1277
  // Blur/change skip untouched text; explicit validation must recheck the
1408
1278
  // current value against the latest constraints regardless of dirtiness.
1409
1279
  if (!options.force && !field.isDirty) return false;
1410
1280
  const result = await field.commit(options);
1281
+ if (session.signal.aborted || !field.isCommitCurrent(result.commitId)) {
1282
+ return false;
1283
+ }
1411
1284
  if (result.status === "stale" || result.status === "invalid") return false;
1412
1285
  this._setValue(result.value || "", {
1413
1286
  emit,
@@ -1420,11 +1293,13 @@ export class DatePickerElement extends HTMLElement {
1420
1293
  /** @param {"start" | "end"} which */
1421
1294
  async _commitFieldText(which) {
1422
1295
  const fields = this._fields;
1423
- if (!fields) return false;
1296
+ const session = this._session;
1297
+ if (!fields || !session) return false;
1424
1298
  const index = which === "end" ? 1 : 0;
1425
1299
  const field = fields[index];
1426
1300
  if (!field?.isDirty) return false;
1427
1301
  const result = await field.commit();
1302
+ if (session.signal.aborted || !field.isCommitCurrent(result.commitId)) return false;
1428
1303
  if (result.status === "stale") return false;
1429
1304
  if (result.status === "invalid") {
1430
1305
  this._orderTags.delete(index);
@@ -1510,7 +1385,6 @@ export class DatePickerElement extends HTMLElement {
1510
1385
  this._previewDate = "";
1511
1386
  this._calendar.removeAttribute("data-range-preview");
1512
1387
  this._calendar.highlightedRange = this._displayRange();
1513
- this._syncDragAffordance();
1514
1388
  }
1515
1389
 
1516
1390
  /**
@@ -1580,6 +1454,14 @@ export class DatePickerElement extends HTMLElement {
1580
1454
  this._timeOrderOwned.clear();
1581
1455
  }
1582
1456
 
1457
+ /** Run a timer only while the connection that scheduled it remains active.
1458
+ * @param {() => void} callback */
1459
+ _defer(callback) {
1460
+ const session = this._session;
1461
+ if (!session) return;
1462
+ setTimeout(guard(session.signal, callback), 0);
1463
+ }
1464
+
1583
1465
  _restoreDefault() {
1584
1466
  const field = this._field;
1585
1467
  if (!field) return;
@@ -1616,10 +1498,10 @@ export class DatePickerElement extends HTMLElement {
1616
1498
  const input = this._fields?.[index]?.input;
1617
1499
  if (!input) return;
1618
1500
  this._suppressFocusOpen = true;
1619
- setTimeout(() => {
1501
+ this._defer(() => {
1620
1502
  input.focus();
1621
1503
  this._suppressFocusOpen = false;
1622
- }, 0);
1504
+ });
1623
1505
  }
1624
1506
 
1625
1507
  /**
@@ -1633,10 +1515,10 @@ export class DatePickerElement extends HTMLElement {
1633
1515
  this._returnFocus = null;
1634
1516
  if (!(target instanceof HTMLElement) || !target.isConnected || target.matches(":disabled")) return;
1635
1517
  this._suppressFocusOpen = true;
1636
- setTimeout(() => {
1518
+ this._defer(() => {
1637
1519
  target.focus();
1638
1520
  this._suppressFocusOpen = false;
1639
- }, 0);
1521
+ });
1640
1522
  }
1641
1523
 
1642
1524
  /** @returns {"" | "start" | "end"} */
@@ -1657,6 +1539,7 @@ export class DatePickerElement extends HTMLElement {
1657
1539
 
1658
1540
  /** @public */
1659
1541
  async validate() {
1542
+ const session = this._session;
1660
1543
  if (this._rangeMode()) {
1661
1544
  const fields = this._fields;
1662
1545
  if (!fields) return true;
@@ -1665,6 +1548,12 @@ export class DatePickerElement extends HTMLElement {
1665
1548
  // Empty text flows through commit()'s "clear" outcome, not an early exit.
1666
1549
  for (const [index, field] of fields.entries()) {
1667
1550
  const result = await field.commit({ moveDisplay: false });
1551
+ // A dead session owns nothing: report authored validity and stop,
1552
+ // instead of revalidating (and writing) the rebuilt instance.
1553
+ if (!session || session.signal.aborted) {
1554
+ return fields.every((field) => field.input.checkValidity());
1555
+ }
1556
+ if (!field.isCommitCurrent(result.commitId)) continue;
1668
1557
  // A parse/source error replaced the order error, which no longer owns it.
1669
1558
  if (result.status === "invalid") this._orderTags.delete(index);
1670
1559
  if (result.status === "ok" || result.status === "clear") {
@@ -1677,10 +1566,29 @@ export class DatePickerElement extends HTMLElement {
1677
1566
  return fields.every((field) => field.input.checkValidity());
1678
1567
  }
1679
1568
  const input = this._field?.input;
1680
- if (!input) return true;
1569
+ if (!input) {
1570
+ const dateInputs = this.hasAttribute("range")
1571
+ ? this.querySelectorAll(":scope > input[data-range-start], :scope > input[data-range-end]")
1572
+ : this._input
1573
+ ? [this._input]
1574
+ : [];
1575
+ const timeInputs = this.querySelectorAll(
1576
+ ":scope > input[type=time][data-time-start], :scope > input[type=time][data-time-end]",
1577
+ );
1578
+ return [...dateInputs, ...timeInputs].every(
1579
+ (candidate) => candidate instanceof HTMLInputElement && candidate.checkValidity(),
1580
+ );
1581
+ }
1681
1582
  // Empty text flows through commit()'s "clear" outcome so the canonical
1682
1583
  // value and hidden ISO are cleared with it.
1683
1584
  await this._commitText(false, { force: true, moveDisplay: false });
1585
+ if (!session || session.signal.aborted) {
1586
+ // Read native validity; never revalidate a rebuilt instance from a dead
1587
+ // operation (the time order would write on the new companions).
1588
+ return (
1589
+ input.checkValidity() && this._timeFields.every((timeInput) => timeInput?.checkValidity() ?? true)
1590
+ );
1591
+ }
1684
1592
  // Same start-then-end order as the range path, so a from/to inversion
1685
1593
  // ends reported on "end". A programmatic time change resyncs here too.
1686
1594
  this._revalidateTimeOrder("start");
@@ -1724,7 +1632,10 @@ export class DatePickerElement extends HTMLElement {
1724
1632
  });
1725
1633
  position();
1726
1634
  this._stopTracking = autoUpdate(this, panel, position);
1727
- if (options.moveFocus !== false) queueMicrotask(() => calendar.focusGrid());
1635
+ if (options.moveFocus !== false) {
1636
+ const session = this._session;
1637
+ if (session) queueMicrotask(guard(session.signal, () => calendar.focusGrid()));
1638
+ }
1728
1639
  this.dispatchEvent(new Event("open", { bubbles: true }));
1729
1640
  }
1730
1641
 
@@ -1749,11 +1660,6 @@ export class DatePickerElement extends HTMLElement {
1749
1660
  this._open = false;
1750
1661
  this._setExpanded(false);
1751
1662
  this._supersedeActivations();
1752
- this._suppressGridClick = false;
1753
- if (this._drag) {
1754
- this._releaseDragCapture(this._drag.pointerId);
1755
- this._drag = null;
1756
- }
1757
1663
  this._syncHighlight();
1758
1664
  if (restoreFocus) {
1759
1665
  if (this._rangeMode()) {