@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.
- package/dist/date-picker.css +2 -13
- package/dist/date-picker.js +122 -257
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +12 -12
- package/dist/date-picker.standalone.min.js +12 -12
- package/dist/types/date-range.d.ts +10 -33
- package/dist/types/date-range.d.ts.map +1 -1
- package/dist/types/session.d.ts +17 -0
- package/dist/types/session.d.ts.map +1 -0
- package/docs/ACCESSIBILITY.md +1 -1
- package/docs/API.md +11 -24
- package/docs/ARCHITECTURE.md +72 -0
- package/docs/CALENDAR-INTEGRATION.md +97 -0
- package/docs/DECISIONS.md +30 -3
- package/docs/TESTING.md +8 -0
- package/docs/USE_CASES.md +1 -2
- package/package.json +1 -1
- package/src/date-calendar.js +42 -25
- package/src/date-field.js +14 -7
- package/src/date-picker.css +2 -13
- package/src/date-picker.js +84 -236
- package/src/date-range.js +12 -54
- package/src/session.js +23 -0
package/src/date-picker.js
CHANGED
|
@@ -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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
this.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
280
|
-
this.
|
|
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
|
-
|
|
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
|
|
697
|
-
|
|
698
|
-
const { signal } =
|
|
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
|
|
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
|
|
796
|
-
|
|
797
|
-
const { signal } =
|
|
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.
|
|
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
|
|
867
|
-
|
|
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
|
|
1019
|
+
* @param {string} date
|
|
1034
1020
|
*/
|
|
1035
|
-
_attachIntent(date
|
|
1021
|
+
_attachIntent(date) {
|
|
1036
1022
|
if (!this._calendar) return;
|
|
1037
|
-
this._calendar._hostIntent = { date, intent: { id: ++this._activationId
|
|
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
|
|
1078
|
+
* @param {string} date
|
|
1093
1079
|
*/
|
|
1094
|
-
_setPreview(date
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
228
|
-
*
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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,
|
|
210
|
+
previewRange(date, editable = () => true) {
|
|
240
211
|
if (!isDate(date)) return null;
|
|
241
|
-
if (
|
|
242
|
-
const transition =
|
|
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 */
|