@lekoala/date-picker 0.3.0 → 0.4.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.
@@ -9,6 +9,9 @@ import { compareTimes, isTime } from "./time.js";
9
9
 
10
10
  let uid = 0;
11
11
 
12
+ /** Pointer travel, in CSS pixels, that turns a press into an endpoint drag. */
13
+ const DRAG_THRESHOLD = 4;
14
+
12
15
  export class DatePickerElement extends HTMLElement {
13
16
  static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
14
17
 
@@ -33,9 +36,17 @@ export class DatePickerElement extends HTMLElement {
33
36
  /** @type {"" | "start" | "end"} */
34
37
  this._lastFocusEndpoint = "";
35
38
  this._rangeCommitId = 0;
36
- /** Map of clicked/keyboard-activated grid dates to the generation at click
37
- * time (range mode supersession/stale protection). @type {Map<string, number>} */
39
+ /** Grid activation intents, keyed by date: the generation at interaction
40
+ * time (range mode supersession/stale protection) plus the bound a drag
41
+ * drop targets, empty for a normal pick.
42
+ * @type {Map<string, {id: number, endpoint: "" | "start" | "end"}>} */
38
43
  this._pendingIntents = new Map();
44
+ /** Candidate date currently projected onto the calendar band, if any. */
45
+ this._previewDate = "";
46
+ /** Live endpoint drag, or null. @type {{pointerId:number, endpoint:"start"|"end", x:number, y:number, date:string, active:boolean} | null} */
47
+ this._drag = null;
48
+ /** A real drag already committed; swallow the click that follows it. */
49
+ this._suppressGridClick = false;
39
50
  /** Overlaid calendar triggers, one per field (single) or per bound (range). @type {{button: HTMLButtonElement, endpoint: "" | "start" | "end"}[]} */
40
51
  this._buttons = [];
41
52
  /** Control that opened the popover, so single mode can restore focus to it.
@@ -90,7 +101,7 @@ export class DatePickerElement extends HTMLElement {
90
101
  this._discoverTimeFields();
91
102
  this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
92
103
  this._field.onAttributesChanged = () => this._syncInputState();
93
- this._field.confirm = (date) => this._confirmDate(date);
104
+ this._field.confirm = (date, options) => this._confirmDate(date, options);
94
105
  this._setupFormValue();
95
106
  this._build();
96
107
  this._syncInputState();
@@ -168,7 +179,7 @@ export class DatePickerElement extends HTMLElement {
168
179
  ];
169
180
  for (const field of this._fields) {
170
181
  field.onAttributesChanged = () => this._syncRangeFields();
171
- field.confirm = (date) => this._confirmDate(date);
182
+ field.confirm = (date, options) => this._confirmDate(date, options);
172
183
  field.setupFormValue();
173
184
  }
174
185
  this._build();
@@ -437,6 +448,7 @@ export class DatePickerElement extends HTMLElement {
437
448
  const field = this._fields[index];
438
449
  button.disabled = Boolean(field?.input.disabled || field?.input.readOnly);
439
450
  }
451
+ this._syncDragAffordance();
440
452
  const active = this._range?.activeEndpoint === "end" ? 1 : 0;
441
453
  if (this._open && (this._fields[active]?.input.disabled || this._fields[active]?.input.readOnly)) {
442
454
  this.hide(false);
@@ -626,6 +638,8 @@ export class DatePickerElement extends HTMLElement {
626
638
  },
627
639
  { signal },
628
640
  );
641
+ // Re-check availability after a source load. validate() commits with
642
+ // moveDisplay:false, so the visible month stays where the user navigated.
629
643
  calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
630
644
  this.ownerDocument.addEventListener(
631
645
  "pointerdown",
@@ -686,7 +700,21 @@ export class DatePickerElement extends HTMLElement {
686
700
  // picker only commits a dateactivate whose generation still matches, so a
687
701
  // response that resolves after the active endpoint changed (or after a
688
702
  // newer activation) is dropped without touching either bound.
689
- calendar.addEventListener("click", (event) => this._captureGridIntent(event), { capture: true, signal });
703
+ calendar.addEventListener(
704
+ "click",
705
+ (event) => {
706
+ if (this._suppressGridClick) {
707
+ // The gesture already committed through the drop path; this click is
708
+ // only the tail of the drag and must not activate a second time.
709
+ this._suppressGridClick = false;
710
+ event.preventDefault();
711
+ event.stopImmediatePropagation();
712
+ return;
713
+ }
714
+ this._captureGridIntent(event);
715
+ },
716
+ { capture: true, signal },
717
+ );
690
718
  calendar.addEventListener("keydown", (event) => this._captureGridIntent(event), {
691
719
  capture: true,
692
720
  signal,
@@ -699,16 +727,17 @@ export class DatePickerElement extends HTMLElement {
699
727
  if (!isDate(date)) return;
700
728
  queueMicrotask(() => {
701
729
  if (custom.defaultPrevented || !this._connected || !this._range) return;
702
- const pending = this._pendingIntents.get(date);
730
+ const intent = this._pendingIntents.get(date);
703
731
  // A response is dropped when the active endpoint changed, the popup
704
732
  // closed or a newer activation superseded this one.
705
- if (pending === undefined || pending !== this._rangeCommitId) return;
733
+ if (intent === undefined || intent.id !== this._rangeCommitId) return;
706
734
  this._pendingIntents.delete(date);
707
- const result = this._range.activate(date, (which) => {
708
- const index = which === "end" ? 1 : 0;
709
- const field = this._fields?.[index];
710
- return field != null && !field.input.disabled && !field.input.readOnly;
711
- });
735
+ const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
736
+ // Click, keyboard and drop share this one machine; only the entry
737
+ // point differs, a drag naming the handle it grabbed.
738
+ const result = intent.endpoint
739
+ ? this._range.moveEndpoint(date, intent.endpoint, editable)
740
+ : this._range.activate(date, editable);
712
741
  if (result.status === "refused") {
713
742
  this._calendar?.dispatchEvent(
714
743
  new CustomEvent("dateinvalid", {
@@ -718,7 +747,10 @@ export class DatePickerElement extends HTMLElement {
718
747
  );
719
748
  return;
720
749
  }
721
- this._setBound(result.endpoint, date, { emit: true, user: true });
750
+ this._applyRangeTransition(result);
751
+ // A drop adjusts an existing range rather than validating it, so the
752
+ // popup stays open for the next adjustment.
753
+ if (intent.endpoint) return;
722
754
  if (result.close || result.status === "complete") {
723
755
  this.hide(false);
724
756
  this._focusField(result.endpoint);
@@ -727,6 +759,18 @@ export class DatePickerElement extends HTMLElement {
727
759
  },
728
760
  { signal },
729
761
  );
762
+ // Preview seams. Hover and keyboard only supply a candidate date; the range
763
+ // machine decides what that candidate would produce.
764
+ calendar.addEventListener("datefocus", (event) => this._onGridDateFocus(event), { signal });
765
+ calendar.addEventListener("pointerover", (event) => this._onGridPointerOver(event), { signal });
766
+ calendar.addEventListener("pointerleave", () => this._onGridPointerLeave(), { signal });
767
+ calendar.addEventListener("pointerdown", (event) => this._onGridPointerDown(event), { signal });
768
+ calendar.addEventListener("pointermove", (event) => this._onGridPointerMove(event), { signal });
769
+ calendar.addEventListener("pointerup", (event) => this._onGridPointerUp(event), { signal });
770
+ calendar.addEventListener("pointercancel", (event) => this._onGridPointerCancel(event), { signal });
771
+ calendar.addEventListener("lostpointercapture", (event) => this._onGridPointerCancel(event), { signal });
772
+ // Re-check availability after a source load. validate() commits with
773
+ // moveDisplay:false, so the visible month stays where the user navigated.
730
774
  calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
731
775
  this.ownerDocument.addEventListener(
732
776
  "pointerdown",
@@ -766,7 +810,10 @@ export class DatePickerElement extends HTMLElement {
766
810
  this._lastFocusEndpoint = endpoint;
767
811
  const target = this._boundCanonical(endpoint) || todayISO();
768
812
  calendar.display = monthKey(target);
813
+ // Roving-focus target only: `focusedDate` without a DOM focus move emits no
814
+ // `datefocus`, so pointing the calendar at a bound never previews anything.
769
815
  calendar.focusedDate = target;
816
+ this._clearPreview();
770
817
  }
771
818
 
772
819
  /**
@@ -870,7 +917,247 @@ export class DatePickerElement extends HTMLElement {
870
917
  if (!isDate(date)) return;
871
918
  // Each activation supersedes every earlier pending one.
872
919
  this._pendingIntents.clear();
873
- this._pendingIntents.set(date, ++this._rangeCommitId);
920
+ this._pendingIntents.set(date, { id: ++this._rangeCommitId, endpoint: "" });
921
+ }
922
+
923
+ /** Whether one bound can receive a user pick right now.
924
+ * @param {"start" | "end"} bound */
925
+ _boundEditable(bound) {
926
+ const field = this._fields?.[bound === "end" ? 1 : 0];
927
+ return field != null && !field.input.disabled && !field.input.readOnly;
928
+ }
929
+
930
+ /**
931
+ * Single write path for a calendar-driven range change. Both fields are
932
+ * synchronized before anything is announced, because one transition can move
933
+ * both bounds at once (`10` then `5` commits `start 10 -> 5` together with
934
+ * `end "" -> 10`); writing bound by bound would expose an intermediate
935
+ * inverted pair. Synthetic `input`/`change` stay on the bounds that moved.
936
+ * @param {import("./date-range.js").RangeTransition} transition
937
+ */
938
+ _applyRangeTransition(transition) {
939
+ const fields = this._fields;
940
+ if (!fields || !this._range || transition.status === "refused") return;
941
+ /** @type {import("./date-field.js").DateFieldController[]} */
942
+ const moved = [];
943
+ for (const which of transition.changedEndpoints) {
944
+ const field = fields[which === "end" ? 1 : 0];
945
+ const next = which === "end" ? transition.range.end : transition.range.start;
946
+ if (!field || field.canonical === next) continue;
947
+ field.setCanonical(next, { format: true });
948
+ moved.push(field);
949
+ }
950
+ this._syncHighlight();
951
+ this._refreshRangeButtonLabel();
952
+ this._revalidateRange(transition.endpoint);
953
+ if (!moved.length) return;
954
+ this._emitRangeChange();
955
+ for (const field of moved) {
956
+ field.input.dispatchEvent(new Event("input", { bubbles: true }));
957
+ field.input.dispatchEvent(new Event("change", { bubbles: true }));
958
+ }
959
+ }
960
+
961
+ /**
962
+ * Project a candidate date onto the calendar band. Purely visual: no
963
+ * `rangechange`, no field write, no validation and no source request — the
964
+ * projection reads the month state already loaded, and the real availability
965
+ * check still runs on commit.
966
+ * @param {string} date @param {"" | "start" | "end"} [endpoint]
967
+ */
968
+ _setPreview(date, endpoint = "") {
969
+ const calendar = this._calendar;
970
+ if (!calendar || !this._range || !this._open) return;
971
+ // A cell already known to be unavailable must not promise a band that the
972
+ // commit would refuse.
973
+ const candidate = isDate(date) && !calendar.getDateState(date).disabled ? date : "";
974
+ const preview = candidate
975
+ ? this._range.previewRange(candidate, endpoint, (bound) => this._boundEditable(bound))
976
+ : null;
977
+ if (!preview) {
978
+ this._clearPreview();
979
+ return;
980
+ }
981
+ this._previewDate = candidate;
982
+ calendar.toggleAttribute("data-range-preview", true);
983
+ calendar.highlightedRange = preview;
984
+ }
985
+
986
+ /** Drop the projection and put the committed range back on the band. */
987
+ _clearPreview() {
988
+ if (!this._previewDate) return;
989
+ this._syncHighlight();
990
+ }
991
+
992
+ /** @param {Event} event */
993
+ _onGridDateFocus(event) {
994
+ // The keyboard candidate is the cell that actually took focus, so Enter
995
+ // commits exactly what the roving focus was previewing.
996
+ if (this._drag) return;
997
+ const date = /** @type {CustomEvent} */ (event).detail?.date;
998
+ if (isDate(date)) this._setPreview(date);
999
+ }
1000
+
1001
+ /**
1002
+ * Hover candidate. `focusedDate` stays the keyboard target only: pointer
1003
+ * hover never moves it, it just feeds the same projection.
1004
+ * @param {Event} event
1005
+ */
1006
+ _onGridPointerOver(event) {
1007
+ const pointer = /** @type {PointerEvent} */ (event);
1008
+ if (this._drag || pointer.pointerType === "touch") return;
1009
+ this._setPreview(this._cellDate(pointer.target));
1010
+ }
1011
+
1012
+ _onGridPointerLeave() {
1013
+ // Before capture, a gesture that leaves the grid can no longer become a
1014
+ // drag; drop it so it cannot block the next press.
1015
+ if (this._drag?.active === false) this._drag = null;
1016
+ if (this._drag) return;
1017
+ this._clearPreview();
1018
+ }
1019
+
1020
+ /** @param {EventTarget | null} target @returns {string} */
1021
+ _cellDate(target) {
1022
+ const cell = target instanceof Element ? target.closest(".dp-day[data-date]") : null;
1023
+ const date = cell?.getAttribute("data-date") || "";
1024
+ return isDate(date) ? date : "";
1025
+ }
1026
+
1027
+ /** @param {number} x @param {number} y @returns {string} */
1028
+ _dateAtPoint(x, y) {
1029
+ const element = this.ownerDocument.elementFromPoint(x, y);
1030
+ if (!(element instanceof Element) || !this._calendar?.contains(element)) return "";
1031
+ return this._cellDate(element);
1032
+ }
1033
+
1034
+ /**
1035
+ * Arm an endpoint drag. Only a complete, ordered range offers two distinct
1036
+ * handles; a one-day range carries both markers on the same cell, and
1037
+ * guessing which one the user meant would be worse than not dragging.
1038
+ * @param {Event} event
1039
+ */
1040
+ _onGridPointerDown(event) {
1041
+ const pointer = /** @type {PointerEvent} */ (event);
1042
+ this._suppressGridClick = false;
1043
+ // An armed gesture that never started and never released (the pointer left
1044
+ // the grid) must not block the next one.
1045
+ if (this._drag?.active === false) this._drag = null;
1046
+ if (!this._open || !this._range || this._drag) return;
1047
+ if (!pointer.isPrimary || pointer.button !== 0) return;
1048
+ // Mouse and pen first: touch keeps native scrolling (no global
1049
+ // `touch-action: none`) and stays a plain tap.
1050
+ if (pointer.pointerType !== "mouse" && pointer.pointerType !== "pen") return;
1051
+ if (!this._range.complete) return;
1052
+ const cell = pointer.target instanceof Element ? pointer.target.closest(".dp-day[data-date]") : null;
1053
+ if (!(cell instanceof HTMLElement)) return;
1054
+ const isStart = cell.hasAttribute("data-range-start");
1055
+ const isEnd = cell.hasAttribute("data-range-end");
1056
+ if (isStart === isEnd) return;
1057
+ const endpoint = isStart ? "start" : "end";
1058
+ const date = this._cellDate(cell);
1059
+ if (!date || !this._boundEditable(endpoint)) return;
1060
+ this._drag = {
1061
+ pointerId: pointer.pointerId,
1062
+ endpoint,
1063
+ x: pointer.clientX,
1064
+ y: pointer.clientY,
1065
+ date,
1066
+ active: false,
1067
+ };
1068
+ }
1069
+
1070
+ /** @param {Event} event */
1071
+ _onGridPointerMove(event) {
1072
+ const pointer = /** @type {PointerEvent} */ (event);
1073
+ const drag = this._drag;
1074
+ if (!drag || pointer.pointerId !== drag.pointerId) return;
1075
+ if (!drag.active) {
1076
+ // A press and release without travel stays a plain click.
1077
+ const travelled =
1078
+ Math.abs(pointer.clientX - drag.x) > DRAG_THRESHOLD ||
1079
+ Math.abs(pointer.clientY - drag.y) > DRAG_THRESHOLD;
1080
+ if (!travelled) return;
1081
+ drag.active = true;
1082
+ // Capture only once the gesture is a drag. Capturing on the press would
1083
+ // retarget the trailing click to the calendar and swallow a plain pick
1084
+ // on the very cell that carries a handle.
1085
+ try {
1086
+ this._calendar?.setPointerCapture(drag.pointerId);
1087
+ } catch {
1088
+ // No active pointer for this id (synthetic event): move and up still
1089
+ // arrive through normal bubbling.
1090
+ }
1091
+ this._syncDragAffordance();
1092
+ }
1093
+ const date = this._dateAtPoint(pointer.clientX, pointer.clientY);
1094
+ // An unavailable cell is not a drop target: hold the last valid projection
1095
+ // instead of promising a move that would be refused.
1096
+ if (!date || this._calendar?.getDateState(date).disabled) return;
1097
+ drag.date = date;
1098
+ this._setPreview(date, drag.endpoint);
1099
+ }
1100
+
1101
+ /** @param {Event} event */
1102
+ _onGridPointerUp(event) {
1103
+ const pointer = /** @type {PointerEvent} */ (event);
1104
+ const drag = this._drag;
1105
+ if (!drag || pointer.pointerId !== drag.pointerId) return;
1106
+ this._drag = null;
1107
+ this._releaseDragCapture(drag.pointerId);
1108
+ if (!drag.active) {
1109
+ this._syncDragAffordance();
1110
+ return;
1111
+ }
1112
+ this._suppressGridClick = true;
1113
+ const transition = this._range?.projectEndpoint(drag.date, drag.endpoint, (bound) =>
1114
+ this._boundEditable(bound),
1115
+ );
1116
+ if (!transition || transition.status === "refused" || !transition.changedEndpoints.length) {
1117
+ this._syncHighlight();
1118
+ return;
1119
+ }
1120
+ // The drop goes through the same availability check and the same
1121
+ // cancelable `dateactivate` seam as a click, under the same generation
1122
+ // guard: a late response can never move a bound after the popup closed or
1123
+ // after a newer interaction.
1124
+ const target = drag.endpoint === "start" ? transition.range.start : transition.range.end;
1125
+ this._pendingIntents.clear();
1126
+ this._pendingIntents.set(target, { id: ++this._rangeCommitId, endpoint: drag.endpoint });
1127
+ void this._calendar?.activateDate(target);
1128
+ }
1129
+
1130
+ /** @param {Event} event */
1131
+ _onGridPointerCancel(event) {
1132
+ const pointer = /** @type {PointerEvent} */ (event);
1133
+ const drag = this._drag;
1134
+ if (!drag || pointer.pointerId !== drag.pointerId) return;
1135
+ this._drag = null;
1136
+ this._releaseDragCapture(drag.pointerId);
1137
+ // A cancelled gesture commits nothing and restores the committed band.
1138
+ this._syncHighlight();
1139
+ }
1140
+
1141
+ /** @param {number} pointerId */
1142
+ _releaseDragCapture(pointerId) {
1143
+ const calendar = this._calendar;
1144
+ if (calendar?.hasPointerCapture(pointerId)) calendar.releasePointerCapture(pointerId);
1145
+ }
1146
+
1147
+ /** Expose whether the band currently offers draggable handles, and whether
1148
+ * one is being dragged, so the cursor can say so. */
1149
+ _syncDragAffordance() {
1150
+ const calendar = this._calendar;
1151
+ if (!calendar || !this._range) return;
1152
+ if (this._drag?.active) {
1153
+ calendar.setAttribute("data-range-drag", "active");
1154
+ return;
1155
+ }
1156
+ const { start, end } = this._range;
1157
+ const draggable =
1158
+ this._range.complete && start !== end && (this._boundEditable("start") || this._boundEditable("end"));
1159
+ if (draggable) calendar.setAttribute("data-range-drag", "ready");
1160
+ else calendar.removeAttribute("data-range-drag");
874
1161
  }
875
1162
 
876
1163
  /** @param {KeyboardEvent} event */
@@ -963,7 +1250,7 @@ export class DatePickerElement extends HTMLElement {
963
1250
  this._reflecting = false;
964
1251
  }
965
1252
 
966
- /** @param {string} value @param {{emit?:boolean,format?:boolean,reflect?:boolean}} [options] */
1253
+ /** @param {string} value @param {{emit?:boolean,format?:boolean,reflect?:boolean,moveDisplay?:boolean}} [options] */
967
1254
  _setValue(value, options = {}) {
968
1255
  if (value && !isDate(value)) throw new TypeError(`Invalid date-picker value: ${value}`);
969
1256
  const previous = this._value;
@@ -972,7 +1259,9 @@ export class DatePickerElement extends HTMLElement {
972
1259
  this._field?.setCanonical(this._value, { format: options.format !== false });
973
1260
  if (this._calendar) {
974
1261
  this._calendar.value = this._value;
975
- if (this._value) this._calendar.focusedDate = this._value;
1262
+ // A background revalidation keeps the user's visible month and keyboard
1263
+ // target; only an explicit commit re-homes them onto the value.
1264
+ if (this._value && options.moveDisplay !== false) this._calendar.focusedDate = this._value;
976
1265
  }
977
1266
  this._refreshButtonLabel();
978
1267
  if (previous !== this._value) {
@@ -989,29 +1278,39 @@ export class DatePickerElement extends HTMLElement {
989
1278
  * Calendar-backed availability gate injected into the field controllers.
990
1279
  * A cancelled or failed source load confirms nothing: do not fill the
991
1280
  * submitted ISO field nor treat the date as available.
992
- * @param {string} date @returns {Promise<{ok: boolean, message?: string}>}
1281
+ * @param {string} date @param {{moveDisplay?: boolean}} [options]
1282
+ * @returns {Promise<{ok: boolean, message?: string}>}
993
1283
  */
994
- async _confirmDate(date) {
1284
+ async _confirmDate(date, options = {}) {
995
1285
  const calendar = this._calendar;
996
1286
  if (!calendar) return { ok: true };
997
- const display = monthKey(date);
998
- if (calendar.display !== display) calendar.display = display;
1287
+ // Only a user-initiated commit may re-home the visible month. A revalidation
1288
+ // (source reload, attribute change) must leave `display` where the user left
1289
+ // it, per the display/value separation contract.
1290
+ if (options.moveDisplay !== false) {
1291
+ const display = monthKey(date);
1292
+ if (calendar.display !== display) calendar.display = display;
1293
+ }
999
1294
  const confirmed = await calendar.ensureDate(date);
1000
1295
  const state = calendar.getDateState(date);
1001
1296
  if (!confirmed || state.disabled) return { ok: false, message: this._messages.unavailableDate };
1002
1297
  return { ok: true };
1003
1298
  }
1004
1299
 
1005
- /** @param {boolean} emit @param {{force?: boolean}} [options] */
1300
+ /** @param {boolean} emit @param {{force?: boolean, moveDisplay?: boolean}} [options] */
1006
1301
  async _commitText(emit, options = {}) {
1007
1302
  const field = this._field;
1008
1303
  if (!field) return false;
1009
1304
  // Blur/change skip untouched text; explicit validation must recheck the
1010
1305
  // current value against the latest constraints regardless of dirtiness.
1011
1306
  if (!options.force && !field.isDirty) return false;
1012
- const result = await field.commit();
1307
+ const result = await field.commit(options);
1013
1308
  if (result.status === "stale" || result.status === "invalid") return false;
1014
- this._setValue(result.value || "", { emit, format: result.status === "ok" });
1309
+ this._setValue(result.value || "", {
1310
+ emit,
1311
+ format: result.status === "ok",
1312
+ moveDisplay: options.moveDisplay,
1313
+ });
1015
1314
  return true;
1016
1315
  }
1017
1316
 
@@ -1092,20 +1391,23 @@ export class DatePickerElement extends HTMLElement {
1092
1391
  this.dispatchEvent(new CustomEvent("rangechange", { detail: { ...this._range.range }, bubbles: true }));
1093
1392
  }
1094
1393
 
1095
- /** Forward only a displayable range to the calendar band. An inverted or
1096
- * start-less business range shows no misleading band. */
1394
+ /** Displayable projection of the business range. An inverted or start-less
1395
+ * pair shows no misleading band. @returns {{start:string, end:string}} */
1396
+ _displayRange() {
1397
+ const { start, end } = this._range?.range ?? { start: "", end: "" };
1398
+ if (!start) return { start: "", end: "" };
1399
+ if (end && compareDates(end, start) < 0) return { start, end: "" };
1400
+ return { start, end };
1401
+ }
1402
+
1403
+ /** Put the committed range back on the calendar band, dropping any
1404
+ * projection currently shown over it. */
1097
1405
  _syncHighlight() {
1098
1406
  if (!this._calendar || !this._range) return;
1099
- const { start, end } = this._range;
1100
- if (!start) {
1101
- this._calendar.highlightedRange = { start: "", end: "" };
1102
- return;
1103
- }
1104
- if (end && compareDates(end, start) < 0) {
1105
- this._calendar.highlightedRange = { start, end: "" };
1106
- } else {
1107
- this._calendar.highlightedRange = { start, end };
1108
- }
1407
+ this._previewDate = "";
1408
+ this._calendar.removeAttribute("data-range-preview");
1409
+ this._calendar.highlightedRange = this._displayRange();
1410
+ this._syncDragAffordance();
1109
1411
  }
1110
1412
 
1111
1413
  /**
@@ -1259,7 +1561,7 @@ export class DatePickerElement extends HTMLElement {
1259
1561
  // field controller, hidden ISO, range model and events stay coherent.
1260
1562
  // Empty text flows through commit()'s "clear" outcome, not an early exit.
1261
1563
  for (const [index, field] of fields.entries()) {
1262
- const result = await field.commit();
1564
+ const result = await field.commit({ moveDisplay: false });
1263
1565
  if (result.status === "ok" || result.status === "clear") {
1264
1566
  const which = index === 1 ? "end" : "start";
1265
1567
  this._setBound(which, result.value || "", { format: result.status === "ok" });
@@ -1274,7 +1576,7 @@ export class DatePickerElement extends HTMLElement {
1274
1576
  if (!input.value.trim()) {
1275
1577
  input.setCustomValidity("");
1276
1578
  } else {
1277
- await this._commitText(false, { force: true });
1579
+ await this._commitText(false, { force: true, moveDisplay: false });
1278
1580
  }
1279
1581
  // Same start-then-end order as the range path, so a from/to inversion
1280
1582
  // ends reported on "end". A programmatic time change resyncs here too.
@@ -1345,6 +1647,12 @@ export class DatePickerElement extends HTMLElement {
1345
1647
  this._setExpanded(false);
1346
1648
  this._rangeCommitId++;
1347
1649
  this._pendingIntents.clear();
1650
+ this._suppressGridClick = false;
1651
+ if (this._drag) {
1652
+ this._releaseDragCapture(this._drag.pointerId);
1653
+ this._drag = null;
1654
+ }
1655
+ this._syncHighlight();
1348
1656
  if (restoreFocus) {
1349
1657
  if (this._rangeMode()) {
1350
1658
  // Range owns a workflow: after a first pick the active bound moved, so