@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.
- package/README.md +8 -6
- package/custom-elements.json +20 -0
- package/dist/date-picker.css +32 -32
- package/dist/date-picker.js +493 -96
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +46 -46
- package/dist/types/date-calendar.d.ts +34 -3
- package/dist/types/date-calendar.d.ts.map +1 -1
- package/dist/types/date-field.d.ts +12 -4
- package/dist/types/date-field.d.ts.map +1 -1
- package/dist/types/date-picker.d.ts +91 -9
- package/dist/types/date-picker.d.ts.map +1 -1
- package/dist/types/date-range.d.ts +102 -20
- package/dist/types/date-range.d.ts.map +1 -1
- package/dist/types/date.d.ts +10 -0
- package/dist/types/date.d.ts.map +1 -1
- package/dist/types/intl.d.ts.map +1 -1
- package/docs/ACCESSIBILITY.md +6 -2
- package/docs/API.md +51 -1
- package/docs/DECISIONS.md +17 -4
- package/docs/MIGRATION-FLATPICKR.md +1 -1
- package/docs/TESTING.md +1 -1
- package/docs/USE_CASES.md +3 -0
- package/package.json +6 -6
- package/src/date-calendar.js +145 -42
- package/src/date-field.js +8 -4
- package/src/date-picker.css +32 -32
- package/src/date-picker.js +345 -37
- package/src/date-range.js +184 -30
- package/src/date.js +50 -1
- package/src/intl.js +13 -7
package/src/date-picker.js
CHANGED
|
@@ -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
|
-
/**
|
|
37
|
-
* time (range mode supersession/stale protection)
|
|
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(
|
|
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
|
|
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 (
|
|
733
|
+
if (intent === undefined || intent.id !== this._rangeCommitId) return;
|
|
706
734
|
this._pendingIntents.delete(date);
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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.
|
|
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
|
-
|
|
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 @
|
|
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
|
-
|
|
998
|
-
|
|
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 || "", {
|
|
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
|
-
/**
|
|
1096
|
-
*
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|