@lekoala/date-picker 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +281 -263
  2. package/custom-elements.json +30 -0
  3. package/dist/date-picker.css +51 -46
  4. package/dist/date-picker.js +685 -204
  5. package/dist/date-picker.min.css +1 -1
  6. package/dist/date-picker.min.js +13 -13
  7. package/dist/date-picker.standalone.min.js +65 -60
  8. package/dist/types/control-classes.d.ts +30 -0
  9. package/dist/types/control-classes.d.ts.map +1 -0
  10. package/dist/types/date-calendar.d.ts +49 -3
  11. package/dist/types/date-calendar.d.ts.map +1 -1
  12. package/dist/types/date-field.d.ts +12 -4
  13. package/dist/types/date-field.d.ts.map +1 -1
  14. package/dist/types/date-picker.d.ts +106 -9
  15. package/dist/types/date-picker.d.ts.map +1 -1
  16. package/dist/types/date-range.d.ts +102 -20
  17. package/dist/types/date-range.d.ts.map +1 -1
  18. package/dist/types/date.d.ts +14 -2
  19. package/dist/types/date.d.ts.map +1 -1
  20. package/dist/types/index.d.ts +1 -0
  21. package/dist/types/index.d.ts.map +1 -1
  22. package/dist/types/intl.d.ts.map +1 -1
  23. package/dist/types/source.d.ts +1 -0
  24. package/dist/types/source.d.ts.map +1 -1
  25. package/docs/ACCESSIBILITY.md +6 -2
  26. package/docs/API.md +430 -358
  27. package/docs/DECISIONS.md +68 -49
  28. package/docs/MIGRATION-FLATPICKR.md +1 -1
  29. package/docs/TESTING.md +1 -1
  30. package/docs/USE_CASES.md +156 -141
  31. package/package.json +8 -8
  32. package/src/control-classes.js +50 -0
  33. package/src/date-calendar.js +188 -54
  34. package/src/date-field.js +9 -5
  35. package/src/date-picker.css +51 -46
  36. package/src/date-picker.js +392 -83
  37. package/src/date-range.js +184 -30
  38. package/src/date.js +57 -3
  39. package/src/index.js +1 -0
  40. package/src/intl.js +52 -17
  41. package/src/source.js +14 -4
@@ -1,4 +1,5 @@
1
1
  import { autoUpdate, reposition } from "@lekoala/floating";
2
+ import { getDefaultControlClasses, resolveControlClasses, sameControlClasses } from "./control-classes.js";
2
3
  import { compareDates, isDate, monthKey, todayISO } from "./date.js";
3
4
  import { DateCalendarElement } from "./date-calendar.js";
4
5
  import { DateFieldController } from "./date-field.js";
@@ -9,6 +10,9 @@ import { compareTimes, isTime } from "./time.js";
9
10
 
10
11
  let uid = 0;
11
12
 
13
+ /** Pointer travel, in CSS pixels, that turns a press into an endpoint drag. */
14
+ const DRAG_THRESHOLD = 4;
15
+
12
16
  export class DatePickerElement extends HTMLElement {
13
17
  static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
14
18
 
@@ -33,9 +37,17 @@ export class DatePickerElement extends HTMLElement {
33
37
  /** @type {"" | "start" | "end"} */
34
38
  this._lastFocusEndpoint = "";
35
39
  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>} */
40
+ /** Grid activation intents, keyed by date: the generation at interaction
41
+ * time (range mode supersession/stale protection) plus the bound a drag
42
+ * drop targets, empty for a normal pick.
43
+ * @type {Map<string, {id: number, endpoint: "" | "start" | "end"}>} */
38
44
  this._pendingIntents = new Map();
45
+ /** Candidate date currently projected onto the calendar band, if any. */
46
+ this._previewDate = "";
47
+ /** Live endpoint drag, or null. @type {{pointerId:number, endpoint:"start"|"end", x:number, y:number, date:string, active:boolean} | null} */
48
+ this._drag = null;
49
+ /** A real drag already committed; swallow the click that follows it. */
50
+ this._suppressGridClick = false;
39
51
  /** Overlaid calendar triggers, one per field (single) or per bound (range). @type {{button: HTMLButtonElement, endpoint: "" | "start" | "end"}[]} */
40
52
  this._buttons = [];
41
53
  /** Control that opened the popover, so single mode can restore focus to it.
@@ -52,6 +64,7 @@ export class DatePickerElement extends HTMLElement {
52
64
  this._open = false;
53
65
  this._suppressFocusOpen = false;
54
66
  this._messages = getDefaultMessages();
67
+ this._controlClasses = getDefaultControlClasses();
55
68
  /** @type {any} */
56
69
  this._source = null;
57
70
  /** @type {any} */
@@ -90,7 +103,7 @@ export class DatePickerElement extends HTMLElement {
90
103
  this._discoverTimeFields();
91
104
  this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
92
105
  this._field.onAttributesChanged = () => this._syncInputState();
93
- this._field.confirm = (date) => this._confirmDate(date);
106
+ this._field.confirm = (date, options) => this._confirmDate(date, options);
94
107
  this._setupFormValue();
95
108
  this._build();
96
109
  this._syncInputState();
@@ -168,7 +181,7 @@ export class DatePickerElement extends HTMLElement {
168
181
  ];
169
182
  for (const field of this._fields) {
170
183
  field.onAttributesChanged = () => this._syncRangeFields();
171
- field.confirm = (date) => this._confirmDate(date);
184
+ field.confirm = (date, options) => this._confirmDate(date, options);
172
185
  field.setupFormValue();
173
186
  }
174
187
  this._build();
@@ -342,6 +355,23 @@ export class DatePickerElement extends HTMLElement {
342
355
  return this._open;
343
356
  }
344
357
 
358
+ /**
359
+ * Author classes for header controls; merged with global defaults.
360
+ * @public
361
+ * @returns {import("./control-classes.js").ControlClasses}
362
+ */
363
+ get controlClasses() {
364
+ return { ...this._controlClasses };
365
+ }
366
+
367
+ /** @param {Partial<import("./control-classes.js").ControlClasses> | null} value */
368
+ set controlClasses(value) {
369
+ const classes = resolveControlClasses(value);
370
+ if (sameControlClasses(classes, this._controlClasses)) return;
371
+ this._controlClasses = classes;
372
+ if (this._calendar) this._calendar.controlClasses = this._controlClasses;
373
+ }
374
+
345
375
  /** @public */
346
376
  get messages() {
347
377
  return this._messages;
@@ -437,6 +467,7 @@ export class DatePickerElement extends HTMLElement {
437
467
  const field = this._fields[index];
438
468
  button.disabled = Boolean(field?.input.disabled || field?.input.readOnly);
439
469
  }
470
+ this._syncDragAffordance();
440
471
  const active = this._range?.activeEndpoint === "end" ? 1 : 0;
441
472
  if (this._open && (this._fields[active]?.input.disabled || this._fields[active]?.input.readOnly)) {
442
473
  this.hide(false);
@@ -541,6 +572,31 @@ export class DatePickerElement extends HTMLElement {
541
572
  this._refreshButtonLabel();
542
573
  }
543
574
 
575
+ /** @param {AbortSignal} signal @param {HTMLInputElement | null | undefined} input @param {() => void} restore */
576
+ _bindPopup(signal, input, restore) {
577
+ this.ownerDocument.addEventListener(
578
+ "pointerdown",
579
+ (event) => {
580
+ if (!this._open || event.composedPath().includes(this)) return;
581
+ this.hide(false);
582
+ },
583
+ { capture: true, signal },
584
+ );
585
+ this.ownerDocument.addEventListener(
586
+ "reset",
587
+ (event) => {
588
+ if (!(event.target instanceof HTMLFormElement) || !input || event.target !== input.form) return;
589
+ // Reset is cancelable and runs before native values have been restored.
590
+ this.hide(false);
591
+ queueMicrotask(() => {
592
+ if (!event.defaultPrevented) restore();
593
+ });
594
+ },
595
+ { capture: true, signal },
596
+ );
597
+ this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
598
+ }
599
+
544
600
  _bind() {
545
601
  const input = this._input;
546
602
  const button = this._buttons[0]?.button;
@@ -626,31 +682,10 @@ export class DatePickerElement extends HTMLElement {
626
682
  },
627
683
  { signal },
628
684
  );
685
+ // Re-check availability after a source load. validate() commits with
686
+ // moveDisplay:false, so the visible month stays where the user navigated.
629
687
  calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
630
- this.ownerDocument.addEventListener(
631
- "pointerdown",
632
- (event) => {
633
- if (!this._open || event.composedPath().includes(this)) return;
634
- this.hide(false);
635
- },
636
- { capture: true, signal },
637
- );
638
- this.ownerDocument.addEventListener(
639
- "reset",
640
- (event) => {
641
- const form = event.target;
642
- if (form instanceof HTMLFormElement && this._input && form === this._input.form) {
643
- // The reset event fires before the control values are restored and
644
- // is cancelable; only resync when the reset actually applies.
645
- this.hide(false);
646
- queueMicrotask(() => {
647
- if (!event.defaultPrevented) this._restoreDefault();
648
- });
649
- }
650
- },
651
- { capture: true, signal },
652
- );
653
- this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
688
+ this._bindPopup(signal, this._input, () => this._restoreDefault());
654
689
  }
655
690
 
656
691
  _bindRange() {
@@ -686,7 +721,21 @@ export class DatePickerElement extends HTMLElement {
686
721
  // picker only commits a dateactivate whose generation still matches, so a
687
722
  // response that resolves after the active endpoint changed (or after a
688
723
  // newer activation) is dropped without touching either bound.
689
- calendar.addEventListener("click", (event) => this._captureGridIntent(event), { capture: true, signal });
724
+ calendar.addEventListener(
725
+ "click",
726
+ (event) => {
727
+ if (this._suppressGridClick) {
728
+ // The gesture already committed through the drop path; this click is
729
+ // only the tail of the drag and must not activate a second time.
730
+ this._suppressGridClick = false;
731
+ event.preventDefault();
732
+ event.stopImmediatePropagation();
733
+ return;
734
+ }
735
+ this._captureGridIntent(event);
736
+ },
737
+ { capture: true, signal },
738
+ );
690
739
  calendar.addEventListener("keydown", (event) => this._captureGridIntent(event), {
691
740
  capture: true,
692
741
  signal,
@@ -699,16 +748,17 @@ export class DatePickerElement extends HTMLElement {
699
748
  if (!isDate(date)) return;
700
749
  queueMicrotask(() => {
701
750
  if (custom.defaultPrevented || !this._connected || !this._range) return;
702
- const pending = this._pendingIntents.get(date);
751
+ const intent = this._pendingIntents.get(date);
703
752
  // A response is dropped when the active endpoint changed, the popup
704
753
  // closed or a newer activation superseded this one.
705
- if (pending === undefined || pending !== this._rangeCommitId) return;
754
+ if (intent === undefined || intent.id !== this._rangeCommitId) return;
706
755
  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
- });
756
+ const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
757
+ // Click, keyboard and drop share this one machine; only the entry
758
+ // point differs, a drag naming the handle it grabbed.
759
+ const result = intent.endpoint
760
+ ? this._range.moveEndpoint(date, intent.endpoint, editable)
761
+ : this._range.activate(date, editable);
712
762
  if (result.status === "refused") {
713
763
  this._calendar?.dispatchEvent(
714
764
  new CustomEvent("dateinvalid", {
@@ -718,7 +768,10 @@ export class DatePickerElement extends HTMLElement {
718
768
  );
719
769
  return;
720
770
  }
721
- this._setBound(result.endpoint, date, { emit: true, user: true });
771
+ this._applyRangeTransition(result);
772
+ // A drop adjusts an existing range rather than validating it, so the
773
+ // popup stays open for the next adjustment.
774
+ if (intent.endpoint) return;
722
775
  if (result.close || result.status === "complete") {
723
776
  this.hide(false);
724
777
  this._focusField(result.endpoint);
@@ -727,29 +780,20 @@ export class DatePickerElement extends HTMLElement {
727
780
  },
728
781
  { signal },
729
782
  );
783
+ // Preview seams. Hover and keyboard only supply a candidate date; the range
784
+ // machine decides what that candidate would produce.
785
+ calendar.addEventListener("datefocus", (event) => this._onGridDateFocus(event), { signal });
786
+ calendar.addEventListener("pointerover", (event) => this._onGridPointerOver(event), { signal });
787
+ calendar.addEventListener("pointerleave", () => this._onGridPointerLeave(), { signal });
788
+ calendar.addEventListener("pointerdown", (event) => this._onGridPointerDown(event), { signal });
789
+ calendar.addEventListener("pointermove", (event) => this._onGridPointerMove(event), { signal });
790
+ calendar.addEventListener("pointerup", (event) => this._onGridPointerUp(event), { signal });
791
+ calendar.addEventListener("pointercancel", (event) => this._onGridPointerCancel(event), { signal });
792
+ calendar.addEventListener("lostpointercapture", (event) => this._onGridPointerCancel(event), { signal });
793
+ // Re-check availability after a source load. validate() commits with
794
+ // moveDisplay:false, so the visible month stays where the user navigated.
730
795
  calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
731
- this.ownerDocument.addEventListener(
732
- "pointerdown",
733
- (event) => {
734
- if (!this._open || event.composedPath().includes(this)) return;
735
- this.hide(false);
736
- },
737
- { capture: true, signal },
738
- );
739
- this.ownerDocument.addEventListener(
740
- "reset",
741
- (event) => {
742
- const form = event.target;
743
- if (form instanceof HTMLFormElement && this._fields && form === this._fields[0].input.form) {
744
- this.hide(false);
745
- queueMicrotask(() => {
746
- if (!event.defaultPrevented) this._restoreRangeDefault();
747
- });
748
- }
749
- },
750
- { capture: true, signal },
751
- );
752
- this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
796
+ this._bindPopup(signal, this._fields?.[0]?.input, () => this._restoreRangeDefault());
753
797
  }
754
798
 
755
799
  /**
@@ -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 */
@@ -890,6 +1177,7 @@ export class DatePickerElement extends HTMLElement {
890
1177
  calendar.min = this.min;
891
1178
  calendar.max = this.max;
892
1179
  calendar.monthFormat = this.monthFormat;
1180
+ calendar.controlClasses = this._controlClasses;
893
1181
  calendar.messages = this._messages;
894
1182
  calendar.source = this._source;
895
1183
  calendar.dateState = this._dateState;
@@ -963,7 +1251,7 @@ export class DatePickerElement extends HTMLElement {
963
1251
  this._reflecting = false;
964
1252
  }
965
1253
 
966
- /** @param {string} value @param {{emit?:boolean,format?:boolean,reflect?:boolean}} [options] */
1254
+ /** @param {string} value @param {{emit?:boolean,format?:boolean,reflect?:boolean,moveDisplay?:boolean}} [options] */
967
1255
  _setValue(value, options = {}) {
968
1256
  if (value && !isDate(value)) throw new TypeError(`Invalid date-picker value: ${value}`);
969
1257
  const previous = this._value;
@@ -972,7 +1260,9 @@ export class DatePickerElement extends HTMLElement {
972
1260
  this._field?.setCanonical(this._value, { format: options.format !== false });
973
1261
  if (this._calendar) {
974
1262
  this._calendar.value = this._value;
975
- if (this._value) this._calendar.focusedDate = this._value;
1263
+ // A background revalidation keeps the user's visible month and keyboard
1264
+ // target; only an explicit commit re-homes them onto the value.
1265
+ if (this._value && options.moveDisplay !== false) this._calendar.focusedDate = this._value;
976
1266
  }
977
1267
  this._refreshButtonLabel();
978
1268
  if (previous !== this._value) {
@@ -989,29 +1279,39 @@ export class DatePickerElement extends HTMLElement {
989
1279
  * Calendar-backed availability gate injected into the field controllers.
990
1280
  * A cancelled or failed source load confirms nothing: do not fill the
991
1281
  * submitted ISO field nor treat the date as available.
992
- * @param {string} date @returns {Promise<{ok: boolean, message?: string}>}
1282
+ * @param {string} date @param {{moveDisplay?: boolean}} [options]
1283
+ * @returns {Promise<{ok: boolean, message?: string}>}
993
1284
  */
994
- async _confirmDate(date) {
1285
+ async _confirmDate(date, options = {}) {
995
1286
  const calendar = this._calendar;
996
1287
  if (!calendar) return { ok: true };
997
- const display = monthKey(date);
998
- if (calendar.display !== display) calendar.display = display;
1288
+ // Only a user-initiated commit may re-home the visible month. A revalidation
1289
+ // (source reload, attribute change) must leave `display` where the user left
1290
+ // it, per the display/value separation contract.
1291
+ if (options.moveDisplay !== false) {
1292
+ const display = monthKey(date);
1293
+ if (calendar.display !== display) calendar.display = display;
1294
+ }
999
1295
  const confirmed = await calendar.ensureDate(date);
1000
1296
  const state = calendar.getDateState(date);
1001
1297
  if (!confirmed || state.disabled) return { ok: false, message: this._messages.unavailableDate };
1002
1298
  return { ok: true };
1003
1299
  }
1004
1300
 
1005
- /** @param {boolean} emit @param {{force?: boolean}} [options] */
1301
+ /** @param {boolean} emit @param {{force?: boolean, moveDisplay?: boolean}} [options] */
1006
1302
  async _commitText(emit, options = {}) {
1007
1303
  const field = this._field;
1008
1304
  if (!field) return false;
1009
1305
  // Blur/change skip untouched text; explicit validation must recheck the
1010
1306
  // current value against the latest constraints regardless of dirtiness.
1011
1307
  if (!options.force && !field.isDirty) return false;
1012
- const result = await field.commit();
1308
+ const result = await field.commit(options);
1013
1309
  if (result.status === "stale" || result.status === "invalid") return false;
1014
- this._setValue(result.value || "", { emit, format: result.status === "ok" });
1310
+ this._setValue(result.value || "", {
1311
+ emit,
1312
+ format: result.status === "ok",
1313
+ moveDisplay: options.moveDisplay,
1314
+ });
1015
1315
  return true;
1016
1316
  }
1017
1317
 
@@ -1092,20 +1392,23 @@ export class DatePickerElement extends HTMLElement {
1092
1392
  this.dispatchEvent(new CustomEvent("rangechange", { detail: { ...this._range.range }, bubbles: true }));
1093
1393
  }
1094
1394
 
1095
- /** Forward only a displayable range to the calendar band. An inverted or
1096
- * start-less business range shows no misleading band. */
1395
+ /** Displayable projection of the business range. An inverted or start-less
1396
+ * pair shows no misleading band. @returns {{start:string, end:string}} */
1397
+ _displayRange() {
1398
+ const { start, end } = this._range?.range ?? { start: "", end: "" };
1399
+ if (!start) return { start: "", end: "" };
1400
+ if (end && compareDates(end, start) < 0) return { start, end: "" };
1401
+ return { start, end };
1402
+ }
1403
+
1404
+ /** Put the committed range back on the calendar band, dropping any
1405
+ * projection currently shown over it. */
1097
1406
  _syncHighlight() {
1098
1407
  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
- }
1408
+ this._previewDate = "";
1409
+ this._calendar.removeAttribute("data-range-preview");
1410
+ this._calendar.highlightedRange = this._displayRange();
1411
+ this._syncDragAffordance();
1109
1412
  }
1110
1413
 
1111
1414
  /**
@@ -1259,7 +1562,7 @@ export class DatePickerElement extends HTMLElement {
1259
1562
  // field controller, hidden ISO, range model and events stay coherent.
1260
1563
  // Empty text flows through commit()'s "clear" outcome, not an early exit.
1261
1564
  for (const [index, field] of fields.entries()) {
1262
- const result = await field.commit();
1565
+ const result = await field.commit({ moveDisplay: false });
1263
1566
  if (result.status === "ok" || result.status === "clear") {
1264
1567
  const which = index === 1 ? "end" : "start";
1265
1568
  this._setBound(which, result.value || "", { format: result.status === "ok" });
@@ -1274,7 +1577,7 @@ export class DatePickerElement extends HTMLElement {
1274
1577
  if (!input.value.trim()) {
1275
1578
  input.setCustomValidity("");
1276
1579
  } else {
1277
- await this._commitText(false, { force: true });
1580
+ await this._commitText(false, { force: true, moveDisplay: false });
1278
1581
  }
1279
1582
  // Same start-then-end order as the range path, so a from/to inversion
1280
1583
  // ends reported on "end". A programmatic time change resyncs here too.
@@ -1345,6 +1648,12 @@ export class DatePickerElement extends HTMLElement {
1345
1648
  this._setExpanded(false);
1346
1649
  this._rangeCommitId++;
1347
1650
  this._pendingIntents.clear();
1651
+ this._suppressGridClick = false;
1652
+ if (this._drag) {
1653
+ this._releaseDragCapture(this._drag.pointerId);
1654
+ this._drag = null;
1655
+ }
1656
+ this._syncHighlight();
1348
1657
  if (restoreFocus) {
1349
1658
  if (this._rangeMode()) {
1350
1659
  // Range owns a workflow: after a first pick the active bound moved, so