@lekoala/date-picker 0.5.3 → 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.
@@ -89,7 +82,8 @@ export class DatePickerElement extends HTMLElement {
89
82
  this._formatHint = null;
90
83
  /** Inputs whose placeholder was generated by this element. */
91
84
  this._generatedPlaceholders = new WeakSet();
92
- this._controller = null;
85
+ /** Connection lifetime token. Captured by every async operation. @type {AbortController | null} */
86
+ this._session = null;
93
87
  this._stopTracking = null;
94
88
  this._open = false;
95
89
  this._suppressFocusOpen = false;
@@ -115,16 +109,9 @@ export class DatePickerElement extends HTMLElement {
115
109
  return this.hasAttribute("range") && Boolean(this._range);
116
110
  }
117
111
 
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
-
126
112
  connectedCallback() {
127
113
  if (this._connected) return;
114
+ this._session = createSession();
128
115
  upgradeProperties(this, [
129
116
  "locale",
130
117
  "min",
@@ -139,17 +126,34 @@ export class DatePickerElement extends HTMLElement {
139
126
  "renderDay",
140
127
  "isDateDisabled",
141
128
  ]);
129
+ this._normalizeAttributes();
142
130
  if (this.hasAttribute("range")) this._connectRange();
143
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
+ }
144
139
  // The range setter requires the controller created by _connectRange().
145
- if (this._connected) {
146
- upgradeProperties(this, ["value", "range"]);
147
- this._stopDefaultMessages = subscribeDefaultMessages(() => {
148
- if (this._messages === null) this._refreshMessages();
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();
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);
153
157
  }
154
158
  }
155
159
 
@@ -276,8 +280,8 @@ export class DatePickerElement extends HTMLElement {
276
280
  this._connected = false;
277
281
  this._suppressFocusOpen = false;
278
282
  this.hide(false);
279
- this._controller?.abort();
280
- this._controller = null;
283
+ this._session?.abort();
284
+ this._session = null;
281
285
  this._clearTimeOrderValidity();
282
286
  this._timeFields = [null, null];
283
287
  if (this._rangeMode()) {
@@ -317,6 +321,9 @@ export class DatePickerElement extends HTMLElement {
317
321
  /** @param {string} name @param {string|null} oldValue @param {string|null} newValue */
318
322
  attributeChangedCallback(name, oldValue, newValue) {
319
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;
320
327
  if (name === "value" && newValue && !isDate(newValue)) {
321
328
  this._reflectValue(this._value);
322
329
  return;
@@ -329,7 +336,6 @@ export class DatePickerElement extends HTMLElement {
329
336
  }
330
337
  return;
331
338
  }
332
- if (!this._connected) return;
333
339
  if (name === "value") {
334
340
  if (this._rangeMode()) return;
335
341
  if (!newValue || isDate(newValue)) {
@@ -559,7 +565,6 @@ export class DatePickerElement extends HTMLElement {
559
565
  const field = this._fields[index];
560
566
  button.disabled = Boolean(field?.input.disabled || field?.input.readOnly);
561
567
  }
562
- this._syncDragAffordance();
563
568
  const active = this._range?.activeEndpoint === "end" ? 1 : 0;
564
569
  const activeInput = this._fields[active]?.input;
565
570
  if (this._open && activeInput && !isEditable(activeInput)) this.hide(false);
@@ -679,9 +684,11 @@ export class DatePickerElement extends HTMLElement {
679
684
  if (!(event.target instanceof HTMLFormElement) || !input || event.target !== input.form) return;
680
685
  // Reset is cancelable and runs before native values have been restored.
681
686
  this.hide(false);
682
- queueMicrotask(() => {
683
- if (!signal.aborted && !event.defaultPrevented) restore();
684
- });
687
+ queueMicrotask(
688
+ guard(signal, () => {
689
+ if (!event.defaultPrevented) restore();
690
+ }),
691
+ );
685
692
  },
686
693
  { capture: true, signal },
687
694
  );
@@ -693,9 +700,9 @@ export class DatePickerElement extends HTMLElement {
693
700
  const button = this._buttons[0]?.button;
694
701
  const calendar = this._calendar;
695
702
  if (!input || !button || !calendar) return;
696
- const controller = new AbortController();
697
- this._controller = controller;
698
- const { signal } = controller;
703
+ const session = this._session;
704
+ if (!session) return;
705
+ const { signal } = session;
699
706
 
700
707
  input.addEventListener(
701
708
  "input",
@@ -769,7 +776,7 @@ export class DatePickerElement extends HTMLElement {
769
776
  // The app listener on <date-picker> runs after this during bubbling;
770
777
  // defer the commit so preventDefault() is actually respected.
771
778
  queueMicrotask(() => {
772
- if (signal.aborted || custom.defaultPrevented || !this._connected) return;
779
+ if (signal.aborted || custom.defaultPrevented) return;
773
780
  // A grid pick that resolves after the popup closed, after a newer pick
774
781
  // or after a programmatic value must not overwrite it. A programmatic
775
782
  // calendar.activateDate() carries no intent and still commits.
@@ -792,9 +799,9 @@ export class DatePickerElement extends HTMLElement {
792
799
  _bindRange() {
793
800
  const calendar = this._calendar;
794
801
  if (!this._fields || !this._buttons.length || !calendar) return;
795
- const controller = new AbortController();
796
- this._controller = controller;
797
- const { signal } = controller;
802
+ const session = this._session;
803
+ if (!session) return;
804
+ const { signal } = session;
798
805
 
799
806
  for (const [index, field] of this._fields.entries()) {
800
807
  const endpoint = index === 1 ? "end" : "start";
@@ -834,14 +841,6 @@ export class DatePickerElement extends HTMLElement {
834
841
  calendar.addEventListener(
835
842
  "click",
836
843
  (event) => {
837
- if (this._suppressGridClick) {
838
- // The gesture already committed through the drop path; this click is
839
- // only the tail of the drag and must not activate a second time.
840
- this._suppressGridClick = false;
841
- event.preventDefault();
842
- event.stopImmediatePropagation();
843
- return;
844
- }
845
844
  this._captureGridIntent(event);
846
845
  },
847
846
  { capture: true, signal },
@@ -857,17 +856,14 @@ export class DatePickerElement extends HTMLElement {
857
856
  const date = custom.detail?.date;
858
857
  if (!isDate(date)) return;
859
858
  queueMicrotask(() => {
860
- if (signal.aborted || custom.defaultPrevented || !this._connected || !this._range) return;
859
+ if (signal.aborted || custom.defaultPrevented || !this._range) return;
861
860
  const intent = /** @type {PickIntent | undefined} */ (activationIntents.get(custom));
862
861
  // A response is dropped when the active endpoint changed, the popup
863
862
  // closed or a newer activation superseded this one.
864
863
  if (intent === undefined || intent.id !== this._activationId) return;
865
864
  const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
866
- // Click, keyboard and drop share this one machine; only the entry
867
- // point differs, a drag naming the handle it grabbed.
868
- const result = intent.endpoint
869
- ? this._range.moveEndpoint(date, intent.endpoint, editable)
870
- : this._range.activate(date, editable);
865
+ // Click and keyboard share this one machine.
866
+ const result = this._range.activate(date, editable);
871
867
  if (result.status === "refused") {
872
868
  this._calendar?.dispatchEvent(
873
869
  new CustomEvent("dateinvalid", {
@@ -878,9 +874,6 @@ export class DatePickerElement extends HTMLElement {
878
874
  return;
879
875
  }
880
876
  this._applyRangeTransition(result);
881
- // A drop adjusts an existing range rather than validating it, so the
882
- // popup stays open for the next adjustment.
883
- if (intent.endpoint) return;
884
877
  if (result.close || result.status === "complete") {
885
878
  this.hide(false);
886
879
  this._focusField(result.endpoint);
@@ -894,11 +887,6 @@ export class DatePickerElement extends HTMLElement {
894
887
  calendar.addEventListener("datefocus", (event) => this._onGridDateFocus(event), { signal });
895
888
  calendar.addEventListener("pointerover", (event) => this._onGridPointerOver(event), { signal });
896
889
  calendar.addEventListener("pointerleave", () => this._onGridPointerLeave(), { signal });
897
- calendar.addEventListener("pointerdown", (event) => this._onGridPointerDown(event), { signal });
898
- calendar.addEventListener("pointermove", (event) => this._onGridPointerMove(event), { signal });
899
- calendar.addEventListener("pointerup", (event) => this._onGridPointerUp(event), { signal });
900
- calendar.addEventListener("pointercancel", (event) => this._onGridPointerCancel(event), { signal });
901
- calendar.addEventListener("lostpointercapture", (event) => this._onGridPointerCancel(event), { signal });
902
890
  // Re-check availability after a source load. validate() commits with
903
891
  // moveDisplay:false, so the visible month stays where the user navigated.
904
892
  calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
@@ -1018,23 +1006,21 @@ export class DatePickerElement extends HTMLElement {
1018
1006
  const key = /** @type {KeyboardEvent} */ (event).key;
1019
1007
  if (key !== "Enter" && key !== " ") return;
1020
1008
  }
1021
- // The click tail of a drag never activates: it must not supersede the drop.
1022
- if (event.type === "click" && this._suppressGridClick) return;
1023
1009
  if (!(event.target instanceof Element)) return;
1024
1010
  const cell = event.target.closest(".dp-day[data-date]");
1025
1011
  const date = cell?.getAttribute("data-date") || "";
1026
1012
  if (!isDate(date)) return;
1027
- this._attachIntent(date, "");
1013
+ this._attachIntent(date);
1028
1014
  }
1029
1015
 
1030
1016
  /**
1031
1017
  * Hand a new intent to the calendar's next activation of `date`. Taking a
1032
1018
  * new generation supersedes every earlier pending activation.
1033
- * @param {string} date @param {"" | "start" | "end"} endpoint
1019
+ * @param {string} date
1034
1020
  */
1035
- _attachIntent(date, endpoint) {
1021
+ _attachIntent(date) {
1036
1022
  if (!this._calendar) return;
1037
- this._calendar._hostIntent = { date, intent: { id: ++this._activationId, endpoint } };
1023
+ this._calendar._hostIntent = { date, intent: { id: ++this._activationId } };
1038
1024
  }
1039
1025
 
1040
1026
  /**
@@ -1089,16 +1075,16 @@ export class DatePickerElement extends HTMLElement {
1089
1075
  * `rangechange`, no field write, no validation and no source request — the
1090
1076
  * projection reads the month state already loaded, and the real availability
1091
1077
  * check still runs on commit.
1092
- * @param {string} date @param {"" | "start" | "end"} [endpoint]
1078
+ * @param {string} date
1093
1079
  */
1094
- _setPreview(date, endpoint = "") {
1080
+ _setPreview(date) {
1095
1081
  const calendar = this._calendar;
1096
1082
  if (!calendar || !this._range || !this._open) return;
1097
1083
  // A cell already known to be unavailable must not promise a band that the
1098
1084
  // commit would refuse.
1099
1085
  const candidate = isDate(date) && !calendar.getDateState(date).disabled ? date : "";
1100
1086
  const preview = candidate
1101
- ? this._range.previewRange(candidate, endpoint, (bound) => this._boundEditable(bound))
1087
+ ? this._range.previewRange(candidate, (bound) => this._boundEditable(bound))
1102
1088
  : null;
1103
1089
  if (!preview) {
1104
1090
  this._clearPreview();
@@ -1119,7 +1105,6 @@ export class DatePickerElement extends HTMLElement {
1119
1105
  _onGridDateFocus(event) {
1120
1106
  // The keyboard candidate is the cell that actually took focus, so Enter
1121
1107
  // commits exactly what the roving focus was previewing.
1122
- if (this._drag) return;
1123
1108
  const date = /** @type {CustomEvent} */ (event).detail?.date;
1124
1109
  if (isDate(date)) this._setPreview(date);
1125
1110
  }
@@ -1131,15 +1116,11 @@ export class DatePickerElement extends HTMLElement {
1131
1116
  */
1132
1117
  _onGridPointerOver(event) {
1133
1118
  const pointer = /** @type {PointerEvent} */ (event);
1134
- if (this._drag || pointer.pointerType === "touch") return;
1119
+ if (pointer.pointerType === "touch") return;
1135
1120
  this._setPreview(this._cellDate(pointer.target));
1136
1121
  }
1137
1122
 
1138
1123
  _onGridPointerLeave() {
1139
- // Before capture, a gesture that leaves the grid can no longer become a
1140
- // drag; drop it so it cannot block the next press.
1141
- if (this._drag?.active === false) this._drag = null;
1142
- if (this._drag) return;
1143
1124
  this._clearPreview();
1144
1125
  }
1145
1126
 
@@ -1150,141 +1131,6 @@ export class DatePickerElement extends HTMLElement {
1150
1131
  return isDate(date) ? date : "";
1151
1132
  }
1152
1133
 
1153
- /** @param {number} x @param {number} y @returns {string} */
1154
- _dateAtPoint(x, y) {
1155
- const element = this.ownerDocument.elementFromPoint(x, y);
1156
- if (!(element instanceof Element) || !this._calendar?.contains(element)) return "";
1157
- return this._cellDate(element);
1158
- }
1159
-
1160
- /**
1161
- * Arm an endpoint drag. Only a complete, ordered range offers two distinct
1162
- * handles; a one-day range carries both markers on the same cell, and
1163
- * guessing which one the user meant would be worse than not dragging.
1164
- * @param {Event} event
1165
- */
1166
- _onGridPointerDown(event) {
1167
- const pointer = /** @type {PointerEvent} */ (event);
1168
- this._suppressGridClick = false;
1169
- // An armed gesture that never started and never released (the pointer left
1170
- // the grid) must not block the next one.
1171
- if (this._drag?.active === false) this._drag = null;
1172
- if (!this._open || !this._range || this._drag) return;
1173
- if (!pointer.isPrimary || pointer.button !== 0) return;
1174
- // Mouse and pen first: touch keeps native scrolling (no global
1175
- // `touch-action: none`) and stays a plain tap.
1176
- if (pointer.pointerType !== "mouse" && pointer.pointerType !== "pen") return;
1177
- if (!this._range.complete) return;
1178
- const cell = pointer.target instanceof Element ? pointer.target.closest(".dp-day[data-date]") : null;
1179
- if (!(cell instanceof HTMLElement)) return;
1180
- const isStart = cell.hasAttribute("data-range-start");
1181
- const isEnd = cell.hasAttribute("data-range-end");
1182
- if (isStart === isEnd) return;
1183
- const endpoint = isStart ? "start" : "end";
1184
- const date = this._cellDate(cell);
1185
- if (!date || !this._boundEditable(endpoint)) return;
1186
- this._drag = {
1187
- pointerId: pointer.pointerId,
1188
- endpoint,
1189
- x: pointer.clientX,
1190
- y: pointer.clientY,
1191
- date,
1192
- active: false,
1193
- };
1194
- }
1195
-
1196
- /** @param {Event} event */
1197
- _onGridPointerMove(event) {
1198
- const pointer = /** @type {PointerEvent} */ (event);
1199
- const drag = this._drag;
1200
- if (!drag || pointer.pointerId !== drag.pointerId) return;
1201
- if (!drag.active) {
1202
- // A press and release without travel stays a plain click.
1203
- const travelled =
1204
- Math.abs(pointer.clientX - drag.x) > DRAG_THRESHOLD ||
1205
- Math.abs(pointer.clientY - drag.y) > DRAG_THRESHOLD;
1206
- if (!travelled) return;
1207
- drag.active = true;
1208
- // Capture only once the gesture is a drag. Capturing on the press would
1209
- // retarget the trailing click to the calendar and swallow a plain pick
1210
- // on the very cell that carries a handle.
1211
- try {
1212
- this._calendar?.setPointerCapture(drag.pointerId);
1213
- } catch {
1214
- // No active pointer for this id (synthetic event): move and up still
1215
- // arrive through normal bubbling.
1216
- }
1217
- this._syncDragAffordance();
1218
- }
1219
- const date = this._dateAtPoint(pointer.clientX, pointer.clientY);
1220
- // An unavailable cell is not a drop target: hold the last valid projection
1221
- // instead of promising a move that would be refused.
1222
- if (!date || this._calendar?.getDateState(date).disabled) return;
1223
- drag.date = date;
1224
- this._setPreview(date, drag.endpoint);
1225
- }
1226
-
1227
- /** @param {Event} event */
1228
- _onGridPointerUp(event) {
1229
- const pointer = /** @type {PointerEvent} */ (event);
1230
- const drag = this._drag;
1231
- if (!drag || pointer.pointerId !== drag.pointerId) return;
1232
- this._drag = null;
1233
- this._releaseDragCapture(drag.pointerId);
1234
- if (!drag.active) {
1235
- this._syncDragAffordance();
1236
- return;
1237
- }
1238
- this._suppressGridClick = true;
1239
- const transition = this._range?.projectEndpoint(drag.date, drag.endpoint, (bound) =>
1240
- this._boundEditable(bound),
1241
- );
1242
- if (!transition || transition.status === "refused" || !transition.changedEndpoints.length) {
1243
- this._syncHighlight();
1244
- return;
1245
- }
1246
- // The drop goes through the same availability check and the same
1247
- // cancelable `dateactivate` seam as a click, under the same generation
1248
- // guard: a late response can never move a bound after the popup closed or
1249
- // after a newer interaction.
1250
- const target = drag.endpoint === "start" ? transition.range.start : transition.range.end;
1251
- this._attachIntent(target, drag.endpoint);
1252
- void this._calendar?.activateDate(target);
1253
- }
1254
-
1255
- /** @param {Event} event */
1256
- _onGridPointerCancel(event) {
1257
- const pointer = /** @type {PointerEvent} */ (event);
1258
- const drag = this._drag;
1259
- if (!drag || pointer.pointerId !== drag.pointerId) return;
1260
- this._drag = null;
1261
- this._releaseDragCapture(drag.pointerId);
1262
- // A cancelled gesture commits nothing and restores the committed band.
1263
- this._syncHighlight();
1264
- }
1265
-
1266
- /** @param {number} pointerId */
1267
- _releaseDragCapture(pointerId) {
1268
- const calendar = this._calendar;
1269
- if (calendar?.hasPointerCapture(pointerId)) calendar.releasePointerCapture(pointerId);
1270
- }
1271
-
1272
- /** Expose whether the band currently offers draggable handles, and whether
1273
- * one is being dragged, so the cursor can say so. */
1274
- _syncDragAffordance() {
1275
- const calendar = this._calendar;
1276
- if (!calendar || !this._range) return;
1277
- if (this._drag?.active) {
1278
- calendar.setAttribute("data-range-drag", "active");
1279
- return;
1280
- }
1281
- const { start, end } = this._range;
1282
- const draggable =
1283
- this._range.complete && start !== end && (this._boundEditable("start") || this._boundEditable("end"));
1284
- if (draggable) calendar.setAttribute("data-range-drag", "ready");
1285
- else calendar.removeAttribute("data-range-drag");
1286
- }
1287
-
1288
1134
  /** @param {KeyboardEvent} event */
1289
1135
  _onEscape(event) {
1290
1136
  if (event.key === "Escape" && this._open) {
@@ -1426,12 +1272,13 @@ export class DatePickerElement extends HTMLElement {
1426
1272
  /** @param {boolean} emit @param {{force?: boolean, moveDisplay?: boolean}} [options] */
1427
1273
  async _commitText(emit, options = {}) {
1428
1274
  const field = this._field;
1429
- if (!field) return false;
1275
+ const session = this._session;
1276
+ if (!field || !session) return false;
1430
1277
  // Blur/change skip untouched text; explicit validation must recheck the
1431
1278
  // current value against the latest constraints regardless of dirtiness.
1432
1279
  if (!options.force && !field.isDirty) return false;
1433
1280
  const result = await field.commit(options);
1434
- if (!this._ownsField(field) || !field.isCommitCurrent(result.commitId)) {
1281
+ if (session.signal.aborted || !field.isCommitCurrent(result.commitId)) {
1435
1282
  return false;
1436
1283
  }
1437
1284
  if (result.status === "stale" || result.status === "invalid") return false;
@@ -1446,12 +1293,13 @@ export class DatePickerElement extends HTMLElement {
1446
1293
  /** @param {"start" | "end"} which */
1447
1294
  async _commitFieldText(which) {
1448
1295
  const fields = this._fields;
1449
- if (!fields) return false;
1296
+ const session = this._session;
1297
+ if (!fields || !session) return false;
1450
1298
  const index = which === "end" ? 1 : 0;
1451
1299
  const field = fields[index];
1452
1300
  if (!field?.isDirty) return false;
1453
1301
  const result = await field.commit();
1454
- if (!this._ownsField(field) || !field.isCommitCurrent(result.commitId)) return false;
1302
+ if (session.signal.aborted || !field.isCommitCurrent(result.commitId)) return false;
1455
1303
  if (result.status === "stale") return false;
1456
1304
  if (result.status === "invalid") {
1457
1305
  this._orderTags.delete(index);
@@ -1537,7 +1385,6 @@ export class DatePickerElement extends HTMLElement {
1537
1385
  this._previewDate = "";
1538
1386
  this._calendar.removeAttribute("data-range-preview");
1539
1387
  this._calendar.highlightedRange = this._displayRange();
1540
- this._syncDragAffordance();
1541
1388
  }
1542
1389
 
1543
1390
  /**
@@ -1610,12 +1457,9 @@ export class DatePickerElement extends HTMLElement {
1610
1457
  /** Run a timer only while the connection that scheduled it remains active.
1611
1458
  * @param {() => void} callback */
1612
1459
  _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);
1460
+ const session = this._session;
1461
+ if (!session) return;
1462
+ setTimeout(guard(session.signal, callback), 0);
1619
1463
  }
1620
1464
 
1621
1465
  _restoreDefault() {
@@ -1695,6 +1539,7 @@ export class DatePickerElement extends HTMLElement {
1695
1539
 
1696
1540
  /** @public */
1697
1541
  async validate() {
1542
+ const session = this._session;
1698
1543
  if (this._rangeMode()) {
1699
1544
  const fields = this._fields;
1700
1545
  if (!fields) return true;
@@ -1703,7 +1548,12 @@ export class DatePickerElement extends HTMLElement {
1703
1548
  // Empty text flows through commit()'s "clear" outcome, not an early exit.
1704
1549
  for (const [index, field] of fields.entries()) {
1705
1550
  const result = await field.commit({ moveDisplay: false });
1706
- if (!this._ownsField(field) || !field.isCommitCurrent(result.commitId)) continue;
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;
1707
1557
  // A parse/source error replaced the order error, which no longer owns it.
1708
1558
  if (result.status === "invalid") this._orderTags.delete(index);
1709
1559
  if (result.status === "ok" || result.status === "clear") {
@@ -1732,6 +1582,13 @@ export class DatePickerElement extends HTMLElement {
1732
1582
  // Empty text flows through commit()'s "clear" outcome so the canonical
1733
1583
  // value and hidden ISO are cleared with it.
1734
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
+ }
1735
1592
  // Same start-then-end order as the range path, so a from/to inversion
1736
1593
  // ends reported on "end". A programmatic time change resyncs here too.
1737
1594
  this._revalidateTimeOrder("start");
@@ -1776,12 +1633,8 @@ export class DatePickerElement extends HTMLElement {
1776
1633
  position();
1777
1634
  this._stopTracking = autoUpdate(this, panel, position);
1778
1635
  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
- });
1636
+ const session = this._session;
1637
+ if (session) queueMicrotask(guard(session.signal, () => calendar.focusGrid()));
1785
1638
  }
1786
1639
  this.dispatchEvent(new Event("open", { bubbles: true }));
1787
1640
  }
@@ -1807,11 +1660,6 @@ export class DatePickerElement extends HTMLElement {
1807
1660
  this._open = false;
1808
1661
  this._setExpanded(false);
1809
1662
  this._supersedeActivations();
1810
- this._suppressGridClick = false;
1811
- if (this._drag) {
1812
- this._releaseDragCapture(this._drag.pointerId);
1813
- this._drag = null;
1814
- }
1815
1663
  this._syncHighlight();
1816
1664
  if (restoreFocus) {
1817
1665
  if (this._rangeMode()) {
package/src/date-range.js CHANGED
@@ -72,10 +72,9 @@ export function rangePosition(date, range) {
72
72
  * - activating through `start` commits `start`, moves the active endpoint to
73
73
  * `end` and leaves the popup open for the second selection.
74
74
  *
75
- * Each rule exists once, as a `project*()` projection; `activate()` and
76
- * `moveEndpoint()` only apply one. Hover, keyboard and drag previews read the
77
- * same projections, so a second copy of the interaction rules cannot drift
78
- * into the DOM layer.
75
+ * Each rule exists once, as a `project()` projection; `activate()` only
76
+ * applies one. Keyboard and pointer previews read the same projection, so a
77
+ * second copy of the interaction rules cannot drift into the DOM layer.
79
78
  *
80
79
  * Text entry is deliberately outside this machine: a typed pair may be
81
80
  * temporarily inverted and report a validation error. It is the coordinator's
@@ -195,53 +194,23 @@ export class DateRangeController {
195
194
  };
196
195
  }
197
196
 
198
- /**
199
- * Project a direct move of one bound (endpoint drag), without mutating.
200
- *
201
- * Only a complete, ordered pair offers two handles to grab, and a handle
202
- * never crosses the other one: the candidate is clamped to the opposite
203
- * bound, so the previewed band is exactly what a drop commits. Equality is
204
- * allowed (a one-day range).
205
- * @param {string} date
206
- * @param {"start" | "end"} endpoint
207
- * @param {(bound: "start" | "end") => boolean} [editable]
208
- * @returns {RangeTransition}
209
- */
210
- projectEndpoint(date, endpoint, editable = () => true) {
211
- if (!isDate(date)) throw new TypeError(`Invalid range activation: ${date}`);
212
- if (!this.complete || !editable(endpoint)) return this._refuse(endpoint);
213
- const { start, end } = this;
214
- const previous = endpoint === "start" ? start : end;
215
- const target = endpoint === "start" ? earlier(date, end) : later(date, start);
216
- return {
217
- status: "complete",
218
- endpoint,
219
- changedEndpoints: target === previous ? [] : [endpoint],
220
- range: endpoint === "start" ? { start: target, end } : { start, end: target },
221
- activeEndpoint: endpoint,
222
- };
223
- }
224
-
225
197
  /**
226
198
  * Displayable band a candidate date would produce, or `null` when there is
227
- * nothing to promise. Single source of truth for hover, keyboard and drag
228
- * previews: the projection rules with no mutation and no event.
199
+ * nothing to promise. Single source of truth for hover and keyboard previews:
200
+ * the projection rules with no mutation and no event.
229
201
  *
230
- * Without a drag handle a preview only speaks during range creation, when
231
- * exactly one bound is anchored and the other is still open. An empty range
232
- * has no anchor to project from, and a complete pair is not a promise in
233
- * progress — previewing it would repaint the committed band on every hover.
202
+ * A preview only speaks during range creation, when exactly one bound is
203
+ * anchored and the other is still open. An empty range has no anchor to
204
+ * project from, and a complete pair is not a promise in progress —
205
+ * previewing it would repaint the committed band on every hover.
234
206
  * @param {string} date
235
- * @param {"" | "start" | "end"} [endpoint] drag handle, empty for a normal pick
236
207
  * @param {(bound: "start" | "end") => boolean} [editable]
237
208
  * @returns {DateRange | null}
238
209
  */
239
- previewRange(date, endpoint = "", editable = () => true) {
210
+ previewRange(date, editable = () => true) {
240
211
  if (!isDate(date)) return null;
241
- if (!endpoint && Boolean(this.start) === Boolean(this.end)) return null;
242
- const transition = endpoint
243
- ? this.projectEndpoint(date, endpoint, editable)
244
- : this.project(date, editable);
212
+ if (Boolean(this.start) === Boolean(this.end)) return null;
213
+ const transition = this.project(date, editable);
245
214
  if (transition.status === "refused") return null;
246
215
  const { start, end } = transition.range;
247
216
  if (!start || (end && compareDates(end, start) < 0)) return null;
@@ -266,17 +235,6 @@ export class DateRangeController {
266
235
  activate(date, editable = () => true) {
267
236
  return this._apply(this.project(date, editable));
268
237
  }
269
-
270
- /**
271
- * Apply a direct move of one bound (endpoint drag).
272
- * @param {string} date
273
- * @param {"start" | "end"} endpoint
274
- * @param {(bound: "start" | "end") => boolean} [editable]
275
- * @returns {RangeTransition}
276
- */
277
- moveEndpoint(date, endpoint, editable = () => true) {
278
- return this._apply(this.projectEndpoint(date, endpoint, editable));
279
- }
280
238
  }
281
239
 
282
240
  /** @param {string} a @param {string} b */