@lekoala/date-picker 0.5.2 → 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 +222 -252
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +12 -13
- package/dist/date-picker.standalone.min.js +11 -12
- package/dist/types/date-calendar.d.ts +2 -98
- package/dist/types/date-picker.d.ts +0 -316
- package/dist/types/date-range.d.ts +10 -33
- package/dist/types/date-range.d.ts.map +1 -1
- package/dist/types/date.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 +14 -25
- package/docs/ARCHITECTURE.md +74 -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 +2 -2
- package/src/date-calendar.js +90 -18
- package/src/date-field.js +32 -9
- package/src/date-picker.css +2 -13
- package/src/date-picker.js +134 -228
- package/src/date-range.js +12 -54
- package/src/date.js +4 -1
- package/src/session.js +23 -0
- package/dist/types/activation-intents.d.ts +0 -10
- package/dist/types/activation-intents.d.ts.map +0 -1
- package/dist/types/date-calendar.d.ts.map +0 -1
- package/dist/types/date-field.d.ts +0 -118
- package/dist/types/date-field.d.ts.map +0 -1
- package/dist/types/date-picker.d.ts.map +0 -1
- package/dist/types/time.d.ts +0 -8
- package/dist/types/time.d.ts.map +0 -1
- package/dist/types/upgrade-properties.d.ts +0 -8
- package/dist/types/upgrade-properties.d.ts.map +0 -1
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.
|
|
@@ -87,8 +80,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
87
80
|
this._originalDescribedBy = "";
|
|
88
81
|
this._rangeOriginalDescribedBy = ["", ""];
|
|
89
82
|
this._formatHint = null;
|
|
90
|
-
this.
|
|
91
|
-
this.
|
|
83
|
+
/** Inputs whose placeholder was generated by this element. */
|
|
84
|
+
this._generatedPlaceholders = new WeakSet();
|
|
85
|
+
/** Connection lifetime token. Captured by every async operation. @type {AbortController | null} */
|
|
86
|
+
this._session = null;
|
|
92
87
|
this._stopTracking = null;
|
|
93
88
|
this._open = false;
|
|
94
89
|
this._suppressFocusOpen = false;
|
|
@@ -116,6 +111,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
116
111
|
|
|
117
112
|
connectedCallback() {
|
|
118
113
|
if (this._connected) return;
|
|
114
|
+
this._session = createSession();
|
|
119
115
|
upgradeProperties(this, [
|
|
120
116
|
"locale",
|
|
121
117
|
"min",
|
|
@@ -130,17 +126,34 @@ export class DatePickerElement extends HTMLElement {
|
|
|
130
126
|
"renderDay",
|
|
131
127
|
"isDateDisabled",
|
|
132
128
|
]);
|
|
129
|
+
this._normalizeAttributes();
|
|
133
130
|
if (this.hasAttribute("range")) this._connectRange();
|
|
134
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
|
+
}
|
|
135
139
|
// The range setter requires the controller created by _connectRange().
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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);
|
|
144
157
|
}
|
|
145
158
|
}
|
|
146
159
|
|
|
@@ -265,9 +278,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
265
278
|
this._stopDefaultMessages?.();
|
|
266
279
|
this._stopDefaultMessages = null;
|
|
267
280
|
this._connected = false;
|
|
281
|
+
this._suppressFocusOpen = false;
|
|
268
282
|
this.hide(false);
|
|
269
|
-
this.
|
|
270
|
-
this.
|
|
283
|
+
this._session?.abort();
|
|
284
|
+
this._session = null;
|
|
271
285
|
this._clearTimeOrderValidity();
|
|
272
286
|
this._timeFields = [null, null];
|
|
273
287
|
if (this._rangeMode()) {
|
|
@@ -306,7 +320,22 @@ export class DatePickerElement extends HTMLElement {
|
|
|
306
320
|
|
|
307
321
|
/** @param {string} name @param {string|null} oldValue @param {string|null} newValue */
|
|
308
322
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
309
|
-
if (
|
|
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;
|
|
327
|
+
if (name === "value" && newValue && !isDate(newValue)) {
|
|
328
|
+
this._reflectValue(this._value);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if ((name === "min" || name === "max") && newValue && !isDate(newValue)) {
|
|
332
|
+
this.removeAttribute(name);
|
|
333
|
+
if (this._connected) {
|
|
334
|
+
this._syncCalendarOptions();
|
|
335
|
+
void this.validate();
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
310
339
|
if (name === "value") {
|
|
311
340
|
if (this._rangeMode()) return;
|
|
312
341
|
if (!newValue || isDate(newValue)) {
|
|
@@ -536,7 +565,6 @@ export class DatePickerElement extends HTMLElement {
|
|
|
536
565
|
const field = this._fields[index];
|
|
537
566
|
button.disabled = Boolean(field?.input.disabled || field?.input.readOnly);
|
|
538
567
|
}
|
|
539
|
-
this._syncDragAffordance();
|
|
540
568
|
const active = this._range?.activeEndpoint === "end" ? 1 : 0;
|
|
541
569
|
const activeInput = this._fields[active]?.input;
|
|
542
570
|
if (this._open && activeInput && !isEditable(activeInput)) this.hide(false);
|
|
@@ -656,9 +684,11 @@ export class DatePickerElement extends HTMLElement {
|
|
|
656
684
|
if (!(event.target instanceof HTMLFormElement) || !input || event.target !== input.form) return;
|
|
657
685
|
// Reset is cancelable and runs before native values have been restored.
|
|
658
686
|
this.hide(false);
|
|
659
|
-
queueMicrotask(
|
|
660
|
-
|
|
661
|
-
|
|
687
|
+
queueMicrotask(
|
|
688
|
+
guard(signal, () => {
|
|
689
|
+
if (!event.defaultPrevented) restore();
|
|
690
|
+
}),
|
|
691
|
+
);
|
|
662
692
|
},
|
|
663
693
|
{ capture: true, signal },
|
|
664
694
|
);
|
|
@@ -670,9 +700,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
670
700
|
const button = this._buttons[0]?.button;
|
|
671
701
|
const calendar = this._calendar;
|
|
672
702
|
if (!input || !button || !calendar) return;
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
const { signal } =
|
|
703
|
+
const session = this._session;
|
|
704
|
+
if (!session) return;
|
|
705
|
+
const { signal } = session;
|
|
676
706
|
|
|
677
707
|
input.addEventListener(
|
|
678
708
|
"input",
|
|
@@ -720,7 +750,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
720
750
|
// open-on-focus, where the popup is already open when Tab reaches the
|
|
721
751
|
// button; Escape keeps closing it. Defer focus as in the input handler.
|
|
722
752
|
if (event.detail === 0) {
|
|
723
|
-
|
|
753
|
+
this._defer(() => this._calendar?.focusGrid());
|
|
724
754
|
return;
|
|
725
755
|
}
|
|
726
756
|
this.hide(false);
|
|
@@ -746,7 +776,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
746
776
|
// The app listener on <date-picker> runs after this during bubbling;
|
|
747
777
|
// defer the commit so preventDefault() is actually respected.
|
|
748
778
|
queueMicrotask(() => {
|
|
749
|
-
if (
|
|
779
|
+
if (signal.aborted || custom.defaultPrevented) return;
|
|
750
780
|
// A grid pick that resolves after the popup closed, after a newer pick
|
|
751
781
|
// or after a programmatic value must not overwrite it. A programmatic
|
|
752
782
|
// calendar.activateDate() carries no intent and still commits.
|
|
@@ -769,9 +799,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
769
799
|
_bindRange() {
|
|
770
800
|
const calendar = this._calendar;
|
|
771
801
|
if (!this._fields || !this._buttons.length || !calendar) return;
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
const { signal } =
|
|
802
|
+
const session = this._session;
|
|
803
|
+
if (!session) return;
|
|
804
|
+
const { signal } = session;
|
|
775
805
|
|
|
776
806
|
for (const [index, field] of this._fields.entries()) {
|
|
777
807
|
const endpoint = index === 1 ? "end" : "start";
|
|
@@ -811,14 +841,6 @@ export class DatePickerElement extends HTMLElement {
|
|
|
811
841
|
calendar.addEventListener(
|
|
812
842
|
"click",
|
|
813
843
|
(event) => {
|
|
814
|
-
if (this._suppressGridClick) {
|
|
815
|
-
// The gesture already committed through the drop path; this click is
|
|
816
|
-
// only the tail of the drag and must not activate a second time.
|
|
817
|
-
this._suppressGridClick = false;
|
|
818
|
-
event.preventDefault();
|
|
819
|
-
event.stopImmediatePropagation();
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
844
|
this._captureGridIntent(event);
|
|
823
845
|
},
|
|
824
846
|
{ capture: true, signal },
|
|
@@ -834,17 +856,14 @@ export class DatePickerElement extends HTMLElement {
|
|
|
834
856
|
const date = custom.detail?.date;
|
|
835
857
|
if (!isDate(date)) return;
|
|
836
858
|
queueMicrotask(() => {
|
|
837
|
-
if (
|
|
859
|
+
if (signal.aborted || custom.defaultPrevented || !this._range) return;
|
|
838
860
|
const intent = /** @type {PickIntent | undefined} */ (activationIntents.get(custom));
|
|
839
861
|
// A response is dropped when the active endpoint changed, the popup
|
|
840
862
|
// closed or a newer activation superseded this one.
|
|
841
863
|
if (intent === undefined || intent.id !== this._activationId) return;
|
|
842
864
|
const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
|
|
843
|
-
// Click
|
|
844
|
-
|
|
845
|
-
const result = intent.endpoint
|
|
846
|
-
? this._range.moveEndpoint(date, intent.endpoint, editable)
|
|
847
|
-
: this._range.activate(date, editable);
|
|
865
|
+
// Click and keyboard share this one machine.
|
|
866
|
+
const result = this._range.activate(date, editable);
|
|
848
867
|
if (result.status === "refused") {
|
|
849
868
|
this._calendar?.dispatchEvent(
|
|
850
869
|
new CustomEvent("dateinvalid", {
|
|
@@ -855,9 +874,6 @@ export class DatePickerElement extends HTMLElement {
|
|
|
855
874
|
return;
|
|
856
875
|
}
|
|
857
876
|
this._applyRangeTransition(result);
|
|
858
|
-
// A drop adjusts an existing range rather than validating it, so the
|
|
859
|
-
// popup stays open for the next adjustment.
|
|
860
|
-
if (intent.endpoint) return;
|
|
861
877
|
if (result.close || result.status === "complete") {
|
|
862
878
|
this.hide(false);
|
|
863
879
|
this._focusField(result.endpoint);
|
|
@@ -871,11 +887,6 @@ export class DatePickerElement extends HTMLElement {
|
|
|
871
887
|
calendar.addEventListener("datefocus", (event) => this._onGridDateFocus(event), { signal });
|
|
872
888
|
calendar.addEventListener("pointerover", (event) => this._onGridPointerOver(event), { signal });
|
|
873
889
|
calendar.addEventListener("pointerleave", () => this._onGridPointerLeave(), { signal });
|
|
874
|
-
calendar.addEventListener("pointerdown", (event) => this._onGridPointerDown(event), { signal });
|
|
875
|
-
calendar.addEventListener("pointermove", (event) => this._onGridPointerMove(event), { signal });
|
|
876
|
-
calendar.addEventListener("pointerup", (event) => this._onGridPointerUp(event), { signal });
|
|
877
|
-
calendar.addEventListener("pointercancel", (event) => this._onGridPointerCancel(event), { signal });
|
|
878
|
-
calendar.addEventListener("lostpointercapture", (event) => this._onGridPointerCancel(event), { signal });
|
|
879
890
|
// Re-check availability after a source load. validate() commits with
|
|
880
891
|
// moveDisplay:false, so the visible month stays where the user navigated.
|
|
881
892
|
calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
|
|
@@ -917,7 +928,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
917
928
|
// Keyboard activation of an already-open picker: switch the bound, then
|
|
918
929
|
// move into the grid. Escape keeps closing it.
|
|
919
930
|
this._activateRangeEndpoint(endpoint);
|
|
920
|
-
|
|
931
|
+
this._defer(() => this._calendar?.focusGrid());
|
|
921
932
|
return;
|
|
922
933
|
}
|
|
923
934
|
if (endpoint !== this._range?.activeEndpoint) {
|
|
@@ -940,7 +951,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
940
951
|
if (this._open) {
|
|
941
952
|
if (this._rangeMode() && endpoint) this._activateRangeEndpoint(endpoint);
|
|
942
953
|
// Chromium drops script-initiated focus changes during keydown dispatch.
|
|
943
|
-
|
|
954
|
+
this._defer(() => this._calendar?.focusGrid());
|
|
944
955
|
return;
|
|
945
956
|
}
|
|
946
957
|
if (this._rangeMode()) this.show({ endpoint: endpoint || undefined });
|
|
@@ -958,7 +969,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
958
969
|
if (this._open) {
|
|
959
970
|
// Chromium drops script-initiated focus changes made during keydown
|
|
960
971
|
// dispatch; defer to a macrotask so the grid can take keyboard focus.
|
|
961
|
-
|
|
972
|
+
this._defer(() => this._calendar?.focusGrid());
|
|
962
973
|
} else {
|
|
963
974
|
this.show();
|
|
964
975
|
}
|
|
@@ -995,23 +1006,21 @@ export class DatePickerElement extends HTMLElement {
|
|
|
995
1006
|
const key = /** @type {KeyboardEvent} */ (event).key;
|
|
996
1007
|
if (key !== "Enter" && key !== " ") return;
|
|
997
1008
|
}
|
|
998
|
-
// The click tail of a drag never activates: it must not supersede the drop.
|
|
999
|
-
if (event.type === "click" && this._suppressGridClick) return;
|
|
1000
1009
|
if (!(event.target instanceof Element)) return;
|
|
1001
1010
|
const cell = event.target.closest(".dp-day[data-date]");
|
|
1002
1011
|
const date = cell?.getAttribute("data-date") || "";
|
|
1003
1012
|
if (!isDate(date)) return;
|
|
1004
|
-
this._attachIntent(date
|
|
1013
|
+
this._attachIntent(date);
|
|
1005
1014
|
}
|
|
1006
1015
|
|
|
1007
1016
|
/**
|
|
1008
1017
|
* Hand a new intent to the calendar's next activation of `date`. Taking a
|
|
1009
1018
|
* new generation supersedes every earlier pending activation.
|
|
1010
|
-
* @param {string} date
|
|
1019
|
+
* @param {string} date
|
|
1011
1020
|
*/
|
|
1012
|
-
_attachIntent(date
|
|
1021
|
+
_attachIntent(date) {
|
|
1013
1022
|
if (!this._calendar) return;
|
|
1014
|
-
this._calendar._hostIntent = { date, intent: { id: ++this._activationId
|
|
1023
|
+
this._calendar._hostIntent = { date, intent: { id: ++this._activationId } };
|
|
1015
1024
|
}
|
|
1016
1025
|
|
|
1017
1026
|
/**
|
|
@@ -1066,16 +1075,16 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1066
1075
|
* `rangechange`, no field write, no validation and no source request — the
|
|
1067
1076
|
* projection reads the month state already loaded, and the real availability
|
|
1068
1077
|
* check still runs on commit.
|
|
1069
|
-
* @param {string} date
|
|
1078
|
+
* @param {string} date
|
|
1070
1079
|
*/
|
|
1071
|
-
_setPreview(date
|
|
1080
|
+
_setPreview(date) {
|
|
1072
1081
|
const calendar = this._calendar;
|
|
1073
1082
|
if (!calendar || !this._range || !this._open) return;
|
|
1074
1083
|
// A cell already known to be unavailable must not promise a band that the
|
|
1075
1084
|
// commit would refuse.
|
|
1076
1085
|
const candidate = isDate(date) && !calendar.getDateState(date).disabled ? date : "";
|
|
1077
1086
|
const preview = candidate
|
|
1078
|
-
? this._range.previewRange(candidate,
|
|
1087
|
+
? this._range.previewRange(candidate, (bound) => this._boundEditable(bound))
|
|
1079
1088
|
: null;
|
|
1080
1089
|
if (!preview) {
|
|
1081
1090
|
this._clearPreview();
|
|
@@ -1096,7 +1105,6 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1096
1105
|
_onGridDateFocus(event) {
|
|
1097
1106
|
// The keyboard candidate is the cell that actually took focus, so Enter
|
|
1098
1107
|
// commits exactly what the roving focus was previewing.
|
|
1099
|
-
if (this._drag) return;
|
|
1100
1108
|
const date = /** @type {CustomEvent} */ (event).detail?.date;
|
|
1101
1109
|
if (isDate(date)) this._setPreview(date);
|
|
1102
1110
|
}
|
|
@@ -1108,15 +1116,11 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1108
1116
|
*/
|
|
1109
1117
|
_onGridPointerOver(event) {
|
|
1110
1118
|
const pointer = /** @type {PointerEvent} */ (event);
|
|
1111
|
-
if (
|
|
1119
|
+
if (pointer.pointerType === "touch") return;
|
|
1112
1120
|
this._setPreview(this._cellDate(pointer.target));
|
|
1113
1121
|
}
|
|
1114
1122
|
|
|
1115
1123
|
_onGridPointerLeave() {
|
|
1116
|
-
// Before capture, a gesture that leaves the grid can no longer become a
|
|
1117
|
-
// drag; drop it so it cannot block the next press.
|
|
1118
|
-
if (this._drag?.active === false) this._drag = null;
|
|
1119
|
-
if (this._drag) return;
|
|
1120
1124
|
this._clearPreview();
|
|
1121
1125
|
}
|
|
1122
1126
|
|
|
@@ -1127,141 +1131,6 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1127
1131
|
return isDate(date) ? date : "";
|
|
1128
1132
|
}
|
|
1129
1133
|
|
|
1130
|
-
/** @param {number} x @param {number} y @returns {string} */
|
|
1131
|
-
_dateAtPoint(x, y) {
|
|
1132
|
-
const element = this.ownerDocument.elementFromPoint(x, y);
|
|
1133
|
-
if (!(element instanceof Element) || !this._calendar?.contains(element)) return "";
|
|
1134
|
-
return this._cellDate(element);
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
/**
|
|
1138
|
-
* Arm an endpoint drag. Only a complete, ordered range offers two distinct
|
|
1139
|
-
* handles; a one-day range carries both markers on the same cell, and
|
|
1140
|
-
* guessing which one the user meant would be worse than not dragging.
|
|
1141
|
-
* @param {Event} event
|
|
1142
|
-
*/
|
|
1143
|
-
_onGridPointerDown(event) {
|
|
1144
|
-
const pointer = /** @type {PointerEvent} */ (event);
|
|
1145
|
-
this._suppressGridClick = false;
|
|
1146
|
-
// An armed gesture that never started and never released (the pointer left
|
|
1147
|
-
// the grid) must not block the next one.
|
|
1148
|
-
if (this._drag?.active === false) this._drag = null;
|
|
1149
|
-
if (!this._open || !this._range || this._drag) return;
|
|
1150
|
-
if (!pointer.isPrimary || pointer.button !== 0) return;
|
|
1151
|
-
// Mouse and pen first: touch keeps native scrolling (no global
|
|
1152
|
-
// `touch-action: none`) and stays a plain tap.
|
|
1153
|
-
if (pointer.pointerType !== "mouse" && pointer.pointerType !== "pen") return;
|
|
1154
|
-
if (!this._range.complete) return;
|
|
1155
|
-
const cell = pointer.target instanceof Element ? pointer.target.closest(".dp-day[data-date]") : null;
|
|
1156
|
-
if (!(cell instanceof HTMLElement)) return;
|
|
1157
|
-
const isStart = cell.hasAttribute("data-range-start");
|
|
1158
|
-
const isEnd = cell.hasAttribute("data-range-end");
|
|
1159
|
-
if (isStart === isEnd) return;
|
|
1160
|
-
const endpoint = isStart ? "start" : "end";
|
|
1161
|
-
const date = this._cellDate(cell);
|
|
1162
|
-
if (!date || !this._boundEditable(endpoint)) return;
|
|
1163
|
-
this._drag = {
|
|
1164
|
-
pointerId: pointer.pointerId,
|
|
1165
|
-
endpoint,
|
|
1166
|
-
x: pointer.clientX,
|
|
1167
|
-
y: pointer.clientY,
|
|
1168
|
-
date,
|
|
1169
|
-
active: false,
|
|
1170
|
-
};
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
/** @param {Event} event */
|
|
1174
|
-
_onGridPointerMove(event) {
|
|
1175
|
-
const pointer = /** @type {PointerEvent} */ (event);
|
|
1176
|
-
const drag = this._drag;
|
|
1177
|
-
if (!drag || pointer.pointerId !== drag.pointerId) return;
|
|
1178
|
-
if (!drag.active) {
|
|
1179
|
-
// A press and release without travel stays a plain click.
|
|
1180
|
-
const travelled =
|
|
1181
|
-
Math.abs(pointer.clientX - drag.x) > DRAG_THRESHOLD ||
|
|
1182
|
-
Math.abs(pointer.clientY - drag.y) > DRAG_THRESHOLD;
|
|
1183
|
-
if (!travelled) return;
|
|
1184
|
-
drag.active = true;
|
|
1185
|
-
// Capture only once the gesture is a drag. Capturing on the press would
|
|
1186
|
-
// retarget the trailing click to the calendar and swallow a plain pick
|
|
1187
|
-
// on the very cell that carries a handle.
|
|
1188
|
-
try {
|
|
1189
|
-
this._calendar?.setPointerCapture(drag.pointerId);
|
|
1190
|
-
} catch {
|
|
1191
|
-
// No active pointer for this id (synthetic event): move and up still
|
|
1192
|
-
// arrive through normal bubbling.
|
|
1193
|
-
}
|
|
1194
|
-
this._syncDragAffordance();
|
|
1195
|
-
}
|
|
1196
|
-
const date = this._dateAtPoint(pointer.clientX, pointer.clientY);
|
|
1197
|
-
// An unavailable cell is not a drop target: hold the last valid projection
|
|
1198
|
-
// instead of promising a move that would be refused.
|
|
1199
|
-
if (!date || this._calendar?.getDateState(date).disabled) return;
|
|
1200
|
-
drag.date = date;
|
|
1201
|
-
this._setPreview(date, drag.endpoint);
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
/** @param {Event} event */
|
|
1205
|
-
_onGridPointerUp(event) {
|
|
1206
|
-
const pointer = /** @type {PointerEvent} */ (event);
|
|
1207
|
-
const drag = this._drag;
|
|
1208
|
-
if (!drag || pointer.pointerId !== drag.pointerId) return;
|
|
1209
|
-
this._drag = null;
|
|
1210
|
-
this._releaseDragCapture(drag.pointerId);
|
|
1211
|
-
if (!drag.active) {
|
|
1212
|
-
this._syncDragAffordance();
|
|
1213
|
-
return;
|
|
1214
|
-
}
|
|
1215
|
-
this._suppressGridClick = true;
|
|
1216
|
-
const transition = this._range?.projectEndpoint(drag.date, drag.endpoint, (bound) =>
|
|
1217
|
-
this._boundEditable(bound),
|
|
1218
|
-
);
|
|
1219
|
-
if (!transition || transition.status === "refused" || !transition.changedEndpoints.length) {
|
|
1220
|
-
this._syncHighlight();
|
|
1221
|
-
return;
|
|
1222
|
-
}
|
|
1223
|
-
// The drop goes through the same availability check and the same
|
|
1224
|
-
// cancelable `dateactivate` seam as a click, under the same generation
|
|
1225
|
-
// guard: a late response can never move a bound after the popup closed or
|
|
1226
|
-
// after a newer interaction.
|
|
1227
|
-
const target = drag.endpoint === "start" ? transition.range.start : transition.range.end;
|
|
1228
|
-
this._attachIntent(target, drag.endpoint);
|
|
1229
|
-
void this._calendar?.activateDate(target);
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
/** @param {Event} event */
|
|
1233
|
-
_onGridPointerCancel(event) {
|
|
1234
|
-
const pointer = /** @type {PointerEvent} */ (event);
|
|
1235
|
-
const drag = this._drag;
|
|
1236
|
-
if (!drag || pointer.pointerId !== drag.pointerId) return;
|
|
1237
|
-
this._drag = null;
|
|
1238
|
-
this._releaseDragCapture(drag.pointerId);
|
|
1239
|
-
// A cancelled gesture commits nothing and restores the committed band.
|
|
1240
|
-
this._syncHighlight();
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
/** @param {number} pointerId */
|
|
1244
|
-
_releaseDragCapture(pointerId) {
|
|
1245
|
-
const calendar = this._calendar;
|
|
1246
|
-
if (calendar?.hasPointerCapture(pointerId)) calendar.releasePointerCapture(pointerId);
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
/** Expose whether the band currently offers draggable handles, and whether
|
|
1250
|
-
* one is being dragged, so the cursor can say so. */
|
|
1251
|
-
_syncDragAffordance() {
|
|
1252
|
-
const calendar = this._calendar;
|
|
1253
|
-
if (!calendar || !this._range) return;
|
|
1254
|
-
if (this._drag?.active) {
|
|
1255
|
-
calendar.setAttribute("data-range-drag", "active");
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
const { start, end } = this._range;
|
|
1259
|
-
const draggable =
|
|
1260
|
-
this._range.complete && start !== end && (this._boundEditable("start") || this._boundEditable("end"));
|
|
1261
|
-
if (draggable) calendar.setAttribute("data-range-drag", "ready");
|
|
1262
|
-
else calendar.removeAttribute("data-range-drag");
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
1134
|
/** @param {KeyboardEvent} event */
|
|
1266
1135
|
_onEscape(event) {
|
|
1267
1136
|
if (event.key === "Escape" && this._open) {
|
|
@@ -1297,9 +1166,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1297
1166
|
this._fields[1].setLocale(this.locale);
|
|
1298
1167
|
hint.textContent = `${this.messages.formatHint}: ${this._fields[0].placeholder}`;
|
|
1299
1168
|
for (const [index, field] of this._fields.entries()) {
|
|
1300
|
-
if (!field.input.hasAttribute("placeholder") || this.
|
|
1169
|
+
if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholders.has(field.input)) {
|
|
1301
1170
|
field.input.placeholder = field.placeholder;
|
|
1302
|
-
this.
|
|
1171
|
+
this._generatedPlaceholders.add(field.input);
|
|
1303
1172
|
}
|
|
1304
1173
|
const canonical = index === 1 ? this._range?.end || "" : this._range?.start || "";
|
|
1305
1174
|
if (canonical) field.input.value = field.format(canonical);
|
|
@@ -1311,9 +1180,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1311
1180
|
if (!field) return;
|
|
1312
1181
|
field.setLocale(this.locale);
|
|
1313
1182
|
hint.textContent = `${this.messages.formatHint}: ${field.placeholder}`;
|
|
1314
|
-
if (!field.input.hasAttribute("placeholder") || this.
|
|
1183
|
+
if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholders.has(field.input)) {
|
|
1315
1184
|
field.input.placeholder = field.placeholder;
|
|
1316
|
-
this.
|
|
1185
|
+
this._generatedPlaceholders.add(field.input);
|
|
1317
1186
|
}
|
|
1318
1187
|
if (this._value) field.input.value = field.format(this._value);
|
|
1319
1188
|
this._refreshButtonLabel();
|
|
@@ -1403,11 +1272,15 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1403
1272
|
/** @param {boolean} emit @param {{force?: boolean, moveDisplay?: boolean}} [options] */
|
|
1404
1273
|
async _commitText(emit, options = {}) {
|
|
1405
1274
|
const field = this._field;
|
|
1406
|
-
|
|
1275
|
+
const session = this._session;
|
|
1276
|
+
if (!field || !session) return false;
|
|
1407
1277
|
// Blur/change skip untouched text; explicit validation must recheck the
|
|
1408
1278
|
// current value against the latest constraints regardless of dirtiness.
|
|
1409
1279
|
if (!options.force && !field.isDirty) return false;
|
|
1410
1280
|
const result = await field.commit(options);
|
|
1281
|
+
if (session.signal.aborted || !field.isCommitCurrent(result.commitId)) {
|
|
1282
|
+
return false;
|
|
1283
|
+
}
|
|
1411
1284
|
if (result.status === "stale" || result.status === "invalid") return false;
|
|
1412
1285
|
this._setValue(result.value || "", {
|
|
1413
1286
|
emit,
|
|
@@ -1420,11 +1293,13 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1420
1293
|
/** @param {"start" | "end"} which */
|
|
1421
1294
|
async _commitFieldText(which) {
|
|
1422
1295
|
const fields = this._fields;
|
|
1423
|
-
|
|
1296
|
+
const session = this._session;
|
|
1297
|
+
if (!fields || !session) return false;
|
|
1424
1298
|
const index = which === "end" ? 1 : 0;
|
|
1425
1299
|
const field = fields[index];
|
|
1426
1300
|
if (!field?.isDirty) return false;
|
|
1427
1301
|
const result = await field.commit();
|
|
1302
|
+
if (session.signal.aborted || !field.isCommitCurrent(result.commitId)) return false;
|
|
1428
1303
|
if (result.status === "stale") return false;
|
|
1429
1304
|
if (result.status === "invalid") {
|
|
1430
1305
|
this._orderTags.delete(index);
|
|
@@ -1510,7 +1385,6 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1510
1385
|
this._previewDate = "";
|
|
1511
1386
|
this._calendar.removeAttribute("data-range-preview");
|
|
1512
1387
|
this._calendar.highlightedRange = this._displayRange();
|
|
1513
|
-
this._syncDragAffordance();
|
|
1514
1388
|
}
|
|
1515
1389
|
|
|
1516
1390
|
/**
|
|
@@ -1580,6 +1454,14 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1580
1454
|
this._timeOrderOwned.clear();
|
|
1581
1455
|
}
|
|
1582
1456
|
|
|
1457
|
+
/** Run a timer only while the connection that scheduled it remains active.
|
|
1458
|
+
* @param {() => void} callback */
|
|
1459
|
+
_defer(callback) {
|
|
1460
|
+
const session = this._session;
|
|
1461
|
+
if (!session) return;
|
|
1462
|
+
setTimeout(guard(session.signal, callback), 0);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1583
1465
|
_restoreDefault() {
|
|
1584
1466
|
const field = this._field;
|
|
1585
1467
|
if (!field) return;
|
|
@@ -1616,10 +1498,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1616
1498
|
const input = this._fields?.[index]?.input;
|
|
1617
1499
|
if (!input) return;
|
|
1618
1500
|
this._suppressFocusOpen = true;
|
|
1619
|
-
|
|
1501
|
+
this._defer(() => {
|
|
1620
1502
|
input.focus();
|
|
1621
1503
|
this._suppressFocusOpen = false;
|
|
1622
|
-
}
|
|
1504
|
+
});
|
|
1623
1505
|
}
|
|
1624
1506
|
|
|
1625
1507
|
/**
|
|
@@ -1633,10 +1515,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1633
1515
|
this._returnFocus = null;
|
|
1634
1516
|
if (!(target instanceof HTMLElement) || !target.isConnected || target.matches(":disabled")) return;
|
|
1635
1517
|
this._suppressFocusOpen = true;
|
|
1636
|
-
|
|
1518
|
+
this._defer(() => {
|
|
1637
1519
|
target.focus();
|
|
1638
1520
|
this._suppressFocusOpen = false;
|
|
1639
|
-
}
|
|
1521
|
+
});
|
|
1640
1522
|
}
|
|
1641
1523
|
|
|
1642
1524
|
/** @returns {"" | "start" | "end"} */
|
|
@@ -1657,6 +1539,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1657
1539
|
|
|
1658
1540
|
/** @public */
|
|
1659
1541
|
async validate() {
|
|
1542
|
+
const session = this._session;
|
|
1660
1543
|
if (this._rangeMode()) {
|
|
1661
1544
|
const fields = this._fields;
|
|
1662
1545
|
if (!fields) return true;
|
|
@@ -1665,6 +1548,12 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1665
1548
|
// Empty text flows through commit()'s "clear" outcome, not an early exit.
|
|
1666
1549
|
for (const [index, field] of fields.entries()) {
|
|
1667
1550
|
const result = await field.commit({ moveDisplay: false });
|
|
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;
|
|
1668
1557
|
// A parse/source error replaced the order error, which no longer owns it.
|
|
1669
1558
|
if (result.status === "invalid") this._orderTags.delete(index);
|
|
1670
1559
|
if (result.status === "ok" || result.status === "clear") {
|
|
@@ -1677,10 +1566,29 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1677
1566
|
return fields.every((field) => field.input.checkValidity());
|
|
1678
1567
|
}
|
|
1679
1568
|
const input = this._field?.input;
|
|
1680
|
-
if (!input)
|
|
1569
|
+
if (!input) {
|
|
1570
|
+
const dateInputs = this.hasAttribute("range")
|
|
1571
|
+
? this.querySelectorAll(":scope > input[data-range-start], :scope > input[data-range-end]")
|
|
1572
|
+
: this._input
|
|
1573
|
+
? [this._input]
|
|
1574
|
+
: [];
|
|
1575
|
+
const timeInputs = this.querySelectorAll(
|
|
1576
|
+
":scope > input[type=time][data-time-start], :scope > input[type=time][data-time-end]",
|
|
1577
|
+
);
|
|
1578
|
+
return [...dateInputs, ...timeInputs].every(
|
|
1579
|
+
(candidate) => candidate instanceof HTMLInputElement && candidate.checkValidity(),
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1681
1582
|
// Empty text flows through commit()'s "clear" outcome so the canonical
|
|
1682
1583
|
// value and hidden ISO are cleared with it.
|
|
1683
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
|
+
}
|
|
1684
1592
|
// Same start-then-end order as the range path, so a from/to inversion
|
|
1685
1593
|
// ends reported on "end". A programmatic time change resyncs here too.
|
|
1686
1594
|
this._revalidateTimeOrder("start");
|
|
@@ -1724,7 +1632,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1724
1632
|
});
|
|
1725
1633
|
position();
|
|
1726
1634
|
this._stopTracking = autoUpdate(this, panel, position);
|
|
1727
|
-
if (options.moveFocus !== false)
|
|
1635
|
+
if (options.moveFocus !== false) {
|
|
1636
|
+
const session = this._session;
|
|
1637
|
+
if (session) queueMicrotask(guard(session.signal, () => calendar.focusGrid()));
|
|
1638
|
+
}
|
|
1728
1639
|
this.dispatchEvent(new Event("open", { bubbles: true }));
|
|
1729
1640
|
}
|
|
1730
1641
|
|
|
@@ -1749,11 +1660,6 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1749
1660
|
this._open = false;
|
|
1750
1661
|
this._setExpanded(false);
|
|
1751
1662
|
this._supersedeActivations();
|
|
1752
|
-
this._suppressGridClick = false;
|
|
1753
|
-
if (this._drag) {
|
|
1754
|
-
this._releaseDragCapture(this._drag.pointerId);
|
|
1755
|
-
this._drag = null;
|
|
1756
|
-
}
|
|
1757
1663
|
this._syncHighlight();
|
|
1758
1664
|
if (restoreFocus) {
|
|
1759
1665
|
if (this._rangeMode()) {
|