@lekoala/date-picker 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +281 -263
- package/custom-elements.json +30 -0
- package/dist/date-picker.css +51 -46
- package/dist/date-picker.js +685 -204
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +65 -60
- package/dist/types/control-classes.d.ts +30 -0
- package/dist/types/control-classes.d.ts.map +1 -0
- package/dist/types/date-calendar.d.ts +49 -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 +106 -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 +14 -2
- package/dist/types/date.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/intl.d.ts.map +1 -1
- package/dist/types/source.d.ts +1 -0
- package/dist/types/source.d.ts.map +1 -1
- package/docs/ACCESSIBILITY.md +6 -2
- package/docs/API.md +430 -358
- package/docs/DECISIONS.md +68 -49
- package/docs/MIGRATION-FLATPICKR.md +1 -1
- package/docs/TESTING.md +1 -1
- package/docs/USE_CASES.md +156 -141
- package/package.json +8 -8
- package/src/control-classes.js +50 -0
- package/src/date-calendar.js +188 -54
- package/src/date-field.js +9 -5
- package/src/date-picker.css +51 -46
- package/src/date-picker.js +392 -83
- package/src/date-range.js +184 -30
- package/src/date.js +57 -3
- package/src/index.js +1 -0
- package/src/intl.js +52 -17
- package/src/source.js +14 -4
package/src/date-picker.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { autoUpdate, reposition } from "@lekoala/floating";
|
|
2
|
+
import { getDefaultControlClasses, resolveControlClasses, sameControlClasses } from "./control-classes.js";
|
|
2
3
|
import { compareDates, isDate, monthKey, todayISO } from "./date.js";
|
|
3
4
|
import { DateCalendarElement } from "./date-calendar.js";
|
|
4
5
|
import { DateFieldController } from "./date-field.js";
|
|
@@ -9,6 +10,9 @@ import { compareTimes, isTime } from "./time.js";
|
|
|
9
10
|
|
|
10
11
|
let uid = 0;
|
|
11
12
|
|
|
13
|
+
/** Pointer travel, in CSS pixels, that turns a press into an endpoint drag. */
|
|
14
|
+
const DRAG_THRESHOLD = 4;
|
|
15
|
+
|
|
12
16
|
export class DatePickerElement extends HTMLElement {
|
|
13
17
|
static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
|
|
14
18
|
|
|
@@ -33,9 +37,17 @@ export class DatePickerElement extends HTMLElement {
|
|
|
33
37
|
/** @type {"" | "start" | "end"} */
|
|
34
38
|
this._lastFocusEndpoint = "";
|
|
35
39
|
this._rangeCommitId = 0;
|
|
36
|
-
/**
|
|
37
|
-
* time (range mode supersession/stale protection)
|
|
40
|
+
/** Grid activation intents, keyed by date: the generation at interaction
|
|
41
|
+
* time (range mode supersession/stale protection) plus the bound a drag
|
|
42
|
+
* drop targets, empty for a normal pick.
|
|
43
|
+
* @type {Map<string, {id: number, endpoint: "" | "start" | "end"}>} */
|
|
38
44
|
this._pendingIntents = new Map();
|
|
45
|
+
/** Candidate date currently projected onto the calendar band, if any. */
|
|
46
|
+
this._previewDate = "";
|
|
47
|
+
/** Live endpoint drag, or null. @type {{pointerId:number, endpoint:"start"|"end", x:number, y:number, date:string, active:boolean} | null} */
|
|
48
|
+
this._drag = null;
|
|
49
|
+
/** A real drag already committed; swallow the click that follows it. */
|
|
50
|
+
this._suppressGridClick = false;
|
|
39
51
|
/** Overlaid calendar triggers, one per field (single) or per bound (range). @type {{button: HTMLButtonElement, endpoint: "" | "start" | "end"}[]} */
|
|
40
52
|
this._buttons = [];
|
|
41
53
|
/** Control that opened the popover, so single mode can restore focus to it.
|
|
@@ -52,6 +64,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
52
64
|
this._open = false;
|
|
53
65
|
this._suppressFocusOpen = false;
|
|
54
66
|
this._messages = getDefaultMessages();
|
|
67
|
+
this._controlClasses = getDefaultControlClasses();
|
|
55
68
|
/** @type {any} */
|
|
56
69
|
this._source = null;
|
|
57
70
|
/** @type {any} */
|
|
@@ -90,7 +103,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
90
103
|
this._discoverTimeFields();
|
|
91
104
|
this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
|
|
92
105
|
this._field.onAttributesChanged = () => this._syncInputState();
|
|
93
|
-
this._field.confirm = (date) => this._confirmDate(date);
|
|
106
|
+
this._field.confirm = (date, options) => this._confirmDate(date, options);
|
|
94
107
|
this._setupFormValue();
|
|
95
108
|
this._build();
|
|
96
109
|
this._syncInputState();
|
|
@@ -168,7 +181,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
168
181
|
];
|
|
169
182
|
for (const field of this._fields) {
|
|
170
183
|
field.onAttributesChanged = () => this._syncRangeFields();
|
|
171
|
-
field.confirm = (date) => this._confirmDate(date);
|
|
184
|
+
field.confirm = (date, options) => this._confirmDate(date, options);
|
|
172
185
|
field.setupFormValue();
|
|
173
186
|
}
|
|
174
187
|
this._build();
|
|
@@ -342,6 +355,23 @@ export class DatePickerElement extends HTMLElement {
|
|
|
342
355
|
return this._open;
|
|
343
356
|
}
|
|
344
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Author classes for header controls; merged with global defaults.
|
|
360
|
+
* @public
|
|
361
|
+
* @returns {import("./control-classes.js").ControlClasses}
|
|
362
|
+
*/
|
|
363
|
+
get controlClasses() {
|
|
364
|
+
return { ...this._controlClasses };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** @param {Partial<import("./control-classes.js").ControlClasses> | null} value */
|
|
368
|
+
set controlClasses(value) {
|
|
369
|
+
const classes = resolveControlClasses(value);
|
|
370
|
+
if (sameControlClasses(classes, this._controlClasses)) return;
|
|
371
|
+
this._controlClasses = classes;
|
|
372
|
+
if (this._calendar) this._calendar.controlClasses = this._controlClasses;
|
|
373
|
+
}
|
|
374
|
+
|
|
345
375
|
/** @public */
|
|
346
376
|
get messages() {
|
|
347
377
|
return this._messages;
|
|
@@ -437,6 +467,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
437
467
|
const field = this._fields[index];
|
|
438
468
|
button.disabled = Boolean(field?.input.disabled || field?.input.readOnly);
|
|
439
469
|
}
|
|
470
|
+
this._syncDragAffordance();
|
|
440
471
|
const active = this._range?.activeEndpoint === "end" ? 1 : 0;
|
|
441
472
|
if (this._open && (this._fields[active]?.input.disabled || this._fields[active]?.input.readOnly)) {
|
|
442
473
|
this.hide(false);
|
|
@@ -541,6 +572,31 @@ export class DatePickerElement extends HTMLElement {
|
|
|
541
572
|
this._refreshButtonLabel();
|
|
542
573
|
}
|
|
543
574
|
|
|
575
|
+
/** @param {AbortSignal} signal @param {HTMLInputElement | null | undefined} input @param {() => void} restore */
|
|
576
|
+
_bindPopup(signal, input, restore) {
|
|
577
|
+
this.ownerDocument.addEventListener(
|
|
578
|
+
"pointerdown",
|
|
579
|
+
(event) => {
|
|
580
|
+
if (!this._open || event.composedPath().includes(this)) return;
|
|
581
|
+
this.hide(false);
|
|
582
|
+
},
|
|
583
|
+
{ capture: true, signal },
|
|
584
|
+
);
|
|
585
|
+
this.ownerDocument.addEventListener(
|
|
586
|
+
"reset",
|
|
587
|
+
(event) => {
|
|
588
|
+
if (!(event.target instanceof HTMLFormElement) || !input || event.target !== input.form) return;
|
|
589
|
+
// Reset is cancelable and runs before native values have been restored.
|
|
590
|
+
this.hide(false);
|
|
591
|
+
queueMicrotask(() => {
|
|
592
|
+
if (!event.defaultPrevented) restore();
|
|
593
|
+
});
|
|
594
|
+
},
|
|
595
|
+
{ capture: true, signal },
|
|
596
|
+
);
|
|
597
|
+
this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
|
|
598
|
+
}
|
|
599
|
+
|
|
544
600
|
_bind() {
|
|
545
601
|
const input = this._input;
|
|
546
602
|
const button = this._buttons[0]?.button;
|
|
@@ -626,31 +682,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
626
682
|
},
|
|
627
683
|
{ signal },
|
|
628
684
|
);
|
|
685
|
+
// Re-check availability after a source load. validate() commits with
|
|
686
|
+
// moveDisplay:false, so the visible month stays where the user navigated.
|
|
629
687
|
calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
|
|
630
|
-
this.
|
|
631
|
-
"pointerdown",
|
|
632
|
-
(event) => {
|
|
633
|
-
if (!this._open || event.composedPath().includes(this)) return;
|
|
634
|
-
this.hide(false);
|
|
635
|
-
},
|
|
636
|
-
{ capture: true, signal },
|
|
637
|
-
);
|
|
638
|
-
this.ownerDocument.addEventListener(
|
|
639
|
-
"reset",
|
|
640
|
-
(event) => {
|
|
641
|
-
const form = event.target;
|
|
642
|
-
if (form instanceof HTMLFormElement && this._input && form === this._input.form) {
|
|
643
|
-
// The reset event fires before the control values are restored and
|
|
644
|
-
// is cancelable; only resync when the reset actually applies.
|
|
645
|
-
this.hide(false);
|
|
646
|
-
queueMicrotask(() => {
|
|
647
|
-
if (!event.defaultPrevented) this._restoreDefault();
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
},
|
|
651
|
-
{ capture: true, signal },
|
|
652
|
-
);
|
|
653
|
-
this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
|
|
688
|
+
this._bindPopup(signal, this._input, () => this._restoreDefault());
|
|
654
689
|
}
|
|
655
690
|
|
|
656
691
|
_bindRange() {
|
|
@@ -686,7 +721,21 @@ export class DatePickerElement extends HTMLElement {
|
|
|
686
721
|
// picker only commits a dateactivate whose generation still matches, so a
|
|
687
722
|
// response that resolves after the active endpoint changed (or after a
|
|
688
723
|
// newer activation) is dropped without touching either bound.
|
|
689
|
-
calendar.addEventListener(
|
|
724
|
+
calendar.addEventListener(
|
|
725
|
+
"click",
|
|
726
|
+
(event) => {
|
|
727
|
+
if (this._suppressGridClick) {
|
|
728
|
+
// The gesture already committed through the drop path; this click is
|
|
729
|
+
// only the tail of the drag and must not activate a second time.
|
|
730
|
+
this._suppressGridClick = false;
|
|
731
|
+
event.preventDefault();
|
|
732
|
+
event.stopImmediatePropagation();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
this._captureGridIntent(event);
|
|
736
|
+
},
|
|
737
|
+
{ capture: true, signal },
|
|
738
|
+
);
|
|
690
739
|
calendar.addEventListener("keydown", (event) => this._captureGridIntent(event), {
|
|
691
740
|
capture: true,
|
|
692
741
|
signal,
|
|
@@ -699,16 +748,17 @@ export class DatePickerElement extends HTMLElement {
|
|
|
699
748
|
if (!isDate(date)) return;
|
|
700
749
|
queueMicrotask(() => {
|
|
701
750
|
if (custom.defaultPrevented || !this._connected || !this._range) return;
|
|
702
|
-
const
|
|
751
|
+
const intent = this._pendingIntents.get(date);
|
|
703
752
|
// A response is dropped when the active endpoint changed, the popup
|
|
704
753
|
// closed or a newer activation superseded this one.
|
|
705
|
-
if (
|
|
754
|
+
if (intent === undefined || intent.id !== this._rangeCommitId) return;
|
|
706
755
|
this._pendingIntents.delete(date);
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
756
|
+
const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
|
|
757
|
+
// Click, keyboard and drop share this one machine; only the entry
|
|
758
|
+
// point differs, a drag naming the handle it grabbed.
|
|
759
|
+
const result = intent.endpoint
|
|
760
|
+
? this._range.moveEndpoint(date, intent.endpoint, editable)
|
|
761
|
+
: this._range.activate(date, editable);
|
|
712
762
|
if (result.status === "refused") {
|
|
713
763
|
this._calendar?.dispatchEvent(
|
|
714
764
|
new CustomEvent("dateinvalid", {
|
|
@@ -718,7 +768,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
718
768
|
);
|
|
719
769
|
return;
|
|
720
770
|
}
|
|
721
|
-
this.
|
|
771
|
+
this._applyRangeTransition(result);
|
|
772
|
+
// A drop adjusts an existing range rather than validating it, so the
|
|
773
|
+
// popup stays open for the next adjustment.
|
|
774
|
+
if (intent.endpoint) return;
|
|
722
775
|
if (result.close || result.status === "complete") {
|
|
723
776
|
this.hide(false);
|
|
724
777
|
this._focusField(result.endpoint);
|
|
@@ -727,29 +780,20 @@ export class DatePickerElement extends HTMLElement {
|
|
|
727
780
|
},
|
|
728
781
|
{ signal },
|
|
729
782
|
);
|
|
783
|
+
// Preview seams. Hover and keyboard only supply a candidate date; the range
|
|
784
|
+
// machine decides what that candidate would produce.
|
|
785
|
+
calendar.addEventListener("datefocus", (event) => this._onGridDateFocus(event), { signal });
|
|
786
|
+
calendar.addEventListener("pointerover", (event) => this._onGridPointerOver(event), { signal });
|
|
787
|
+
calendar.addEventListener("pointerleave", () => this._onGridPointerLeave(), { signal });
|
|
788
|
+
calendar.addEventListener("pointerdown", (event) => this._onGridPointerDown(event), { signal });
|
|
789
|
+
calendar.addEventListener("pointermove", (event) => this._onGridPointerMove(event), { signal });
|
|
790
|
+
calendar.addEventListener("pointerup", (event) => this._onGridPointerUp(event), { signal });
|
|
791
|
+
calendar.addEventListener("pointercancel", (event) => this._onGridPointerCancel(event), { signal });
|
|
792
|
+
calendar.addEventListener("lostpointercapture", (event) => this._onGridPointerCancel(event), { signal });
|
|
793
|
+
// Re-check availability after a source load. validate() commits with
|
|
794
|
+
// moveDisplay:false, so the visible month stays where the user navigated.
|
|
730
795
|
calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
|
|
731
|
-
this.
|
|
732
|
-
"pointerdown",
|
|
733
|
-
(event) => {
|
|
734
|
-
if (!this._open || event.composedPath().includes(this)) return;
|
|
735
|
-
this.hide(false);
|
|
736
|
-
},
|
|
737
|
-
{ capture: true, signal },
|
|
738
|
-
);
|
|
739
|
-
this.ownerDocument.addEventListener(
|
|
740
|
-
"reset",
|
|
741
|
-
(event) => {
|
|
742
|
-
const form = event.target;
|
|
743
|
-
if (form instanceof HTMLFormElement && this._fields && form === this._fields[0].input.form) {
|
|
744
|
-
this.hide(false);
|
|
745
|
-
queueMicrotask(() => {
|
|
746
|
-
if (!event.defaultPrevented) this._restoreRangeDefault();
|
|
747
|
-
});
|
|
748
|
-
}
|
|
749
|
-
},
|
|
750
|
-
{ capture: true, signal },
|
|
751
|
-
);
|
|
752
|
-
this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
|
|
796
|
+
this._bindPopup(signal, this._fields?.[0]?.input, () => this._restoreRangeDefault());
|
|
753
797
|
}
|
|
754
798
|
|
|
755
799
|
/**
|
|
@@ -766,7 +810,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
766
810
|
this._lastFocusEndpoint = endpoint;
|
|
767
811
|
const target = this._boundCanonical(endpoint) || todayISO();
|
|
768
812
|
calendar.display = monthKey(target);
|
|
813
|
+
// Roving-focus target only: `focusedDate` without a DOM focus move emits no
|
|
814
|
+
// `datefocus`, so pointing the calendar at a bound never previews anything.
|
|
769
815
|
calendar.focusedDate = target;
|
|
816
|
+
this._clearPreview();
|
|
770
817
|
}
|
|
771
818
|
|
|
772
819
|
/**
|
|
@@ -870,7 +917,247 @@ export class DatePickerElement extends HTMLElement {
|
|
|
870
917
|
if (!isDate(date)) return;
|
|
871
918
|
// Each activation supersedes every earlier pending one.
|
|
872
919
|
this._pendingIntents.clear();
|
|
873
|
-
this._pendingIntents.set(date, ++this._rangeCommitId);
|
|
920
|
+
this._pendingIntents.set(date, { id: ++this._rangeCommitId, endpoint: "" });
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Whether one bound can receive a user pick right now.
|
|
924
|
+
* @param {"start" | "end"} bound */
|
|
925
|
+
_boundEditable(bound) {
|
|
926
|
+
const field = this._fields?.[bound === "end" ? 1 : 0];
|
|
927
|
+
return field != null && !field.input.disabled && !field.input.readOnly;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Single write path for a calendar-driven range change. Both fields are
|
|
932
|
+
* synchronized before anything is announced, because one transition can move
|
|
933
|
+
* both bounds at once (`10` then `5` commits `start 10 -> 5` together with
|
|
934
|
+
* `end "" -> 10`); writing bound by bound would expose an intermediate
|
|
935
|
+
* inverted pair. Synthetic `input`/`change` stay on the bounds that moved.
|
|
936
|
+
* @param {import("./date-range.js").RangeTransition} transition
|
|
937
|
+
*/
|
|
938
|
+
_applyRangeTransition(transition) {
|
|
939
|
+
const fields = this._fields;
|
|
940
|
+
if (!fields || !this._range || transition.status === "refused") return;
|
|
941
|
+
/** @type {import("./date-field.js").DateFieldController[]} */
|
|
942
|
+
const moved = [];
|
|
943
|
+
for (const which of transition.changedEndpoints) {
|
|
944
|
+
const field = fields[which === "end" ? 1 : 0];
|
|
945
|
+
const next = which === "end" ? transition.range.end : transition.range.start;
|
|
946
|
+
if (!field || field.canonical === next) continue;
|
|
947
|
+
field.setCanonical(next, { format: true });
|
|
948
|
+
moved.push(field);
|
|
949
|
+
}
|
|
950
|
+
this._syncHighlight();
|
|
951
|
+
this._refreshRangeButtonLabel();
|
|
952
|
+
this._revalidateRange(transition.endpoint);
|
|
953
|
+
if (!moved.length) return;
|
|
954
|
+
this._emitRangeChange();
|
|
955
|
+
for (const field of moved) {
|
|
956
|
+
field.input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
957
|
+
field.input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Project a candidate date onto the calendar band. Purely visual: no
|
|
963
|
+
* `rangechange`, no field write, no validation and no source request — the
|
|
964
|
+
* projection reads the month state already loaded, and the real availability
|
|
965
|
+
* check still runs on commit.
|
|
966
|
+
* @param {string} date @param {"" | "start" | "end"} [endpoint]
|
|
967
|
+
*/
|
|
968
|
+
_setPreview(date, endpoint = "") {
|
|
969
|
+
const calendar = this._calendar;
|
|
970
|
+
if (!calendar || !this._range || !this._open) return;
|
|
971
|
+
// A cell already known to be unavailable must not promise a band that the
|
|
972
|
+
// commit would refuse.
|
|
973
|
+
const candidate = isDate(date) && !calendar.getDateState(date).disabled ? date : "";
|
|
974
|
+
const preview = candidate
|
|
975
|
+
? this._range.previewRange(candidate, endpoint, (bound) => this._boundEditable(bound))
|
|
976
|
+
: null;
|
|
977
|
+
if (!preview) {
|
|
978
|
+
this._clearPreview();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
this._previewDate = candidate;
|
|
982
|
+
calendar.toggleAttribute("data-range-preview", true);
|
|
983
|
+
calendar.highlightedRange = preview;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** Drop the projection and put the committed range back on the band. */
|
|
987
|
+
_clearPreview() {
|
|
988
|
+
if (!this._previewDate) return;
|
|
989
|
+
this._syncHighlight();
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** @param {Event} event */
|
|
993
|
+
_onGridDateFocus(event) {
|
|
994
|
+
// The keyboard candidate is the cell that actually took focus, so Enter
|
|
995
|
+
// commits exactly what the roving focus was previewing.
|
|
996
|
+
if (this._drag) return;
|
|
997
|
+
const date = /** @type {CustomEvent} */ (event).detail?.date;
|
|
998
|
+
if (isDate(date)) this._setPreview(date);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Hover candidate. `focusedDate` stays the keyboard target only: pointer
|
|
1003
|
+
* hover never moves it, it just feeds the same projection.
|
|
1004
|
+
* @param {Event} event
|
|
1005
|
+
*/
|
|
1006
|
+
_onGridPointerOver(event) {
|
|
1007
|
+
const pointer = /** @type {PointerEvent} */ (event);
|
|
1008
|
+
if (this._drag || pointer.pointerType === "touch") return;
|
|
1009
|
+
this._setPreview(this._cellDate(pointer.target));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
_onGridPointerLeave() {
|
|
1013
|
+
// Before capture, a gesture that leaves the grid can no longer become a
|
|
1014
|
+
// drag; drop it so it cannot block the next press.
|
|
1015
|
+
if (this._drag?.active === false) this._drag = null;
|
|
1016
|
+
if (this._drag) return;
|
|
1017
|
+
this._clearPreview();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** @param {EventTarget | null} target @returns {string} */
|
|
1021
|
+
_cellDate(target) {
|
|
1022
|
+
const cell = target instanceof Element ? target.closest(".dp-day[data-date]") : null;
|
|
1023
|
+
const date = cell?.getAttribute("data-date") || "";
|
|
1024
|
+
return isDate(date) ? date : "";
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** @param {number} x @param {number} y @returns {string} */
|
|
1028
|
+
_dateAtPoint(x, y) {
|
|
1029
|
+
const element = this.ownerDocument.elementFromPoint(x, y);
|
|
1030
|
+
if (!(element instanceof Element) || !this._calendar?.contains(element)) return "";
|
|
1031
|
+
return this._cellDate(element);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Arm an endpoint drag. Only a complete, ordered range offers two distinct
|
|
1036
|
+
* handles; a one-day range carries both markers on the same cell, and
|
|
1037
|
+
* guessing which one the user meant would be worse than not dragging.
|
|
1038
|
+
* @param {Event} event
|
|
1039
|
+
*/
|
|
1040
|
+
_onGridPointerDown(event) {
|
|
1041
|
+
const pointer = /** @type {PointerEvent} */ (event);
|
|
1042
|
+
this._suppressGridClick = false;
|
|
1043
|
+
// An armed gesture that never started and never released (the pointer left
|
|
1044
|
+
// the grid) must not block the next one.
|
|
1045
|
+
if (this._drag?.active === false) this._drag = null;
|
|
1046
|
+
if (!this._open || !this._range || this._drag) return;
|
|
1047
|
+
if (!pointer.isPrimary || pointer.button !== 0) return;
|
|
1048
|
+
// Mouse and pen first: touch keeps native scrolling (no global
|
|
1049
|
+
// `touch-action: none`) and stays a plain tap.
|
|
1050
|
+
if (pointer.pointerType !== "mouse" && pointer.pointerType !== "pen") return;
|
|
1051
|
+
if (!this._range.complete) return;
|
|
1052
|
+
const cell = pointer.target instanceof Element ? pointer.target.closest(".dp-day[data-date]") : null;
|
|
1053
|
+
if (!(cell instanceof HTMLElement)) return;
|
|
1054
|
+
const isStart = cell.hasAttribute("data-range-start");
|
|
1055
|
+
const isEnd = cell.hasAttribute("data-range-end");
|
|
1056
|
+
if (isStart === isEnd) return;
|
|
1057
|
+
const endpoint = isStart ? "start" : "end";
|
|
1058
|
+
const date = this._cellDate(cell);
|
|
1059
|
+
if (!date || !this._boundEditable(endpoint)) return;
|
|
1060
|
+
this._drag = {
|
|
1061
|
+
pointerId: pointer.pointerId,
|
|
1062
|
+
endpoint,
|
|
1063
|
+
x: pointer.clientX,
|
|
1064
|
+
y: pointer.clientY,
|
|
1065
|
+
date,
|
|
1066
|
+
active: false,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** @param {Event} event */
|
|
1071
|
+
_onGridPointerMove(event) {
|
|
1072
|
+
const pointer = /** @type {PointerEvent} */ (event);
|
|
1073
|
+
const drag = this._drag;
|
|
1074
|
+
if (!drag || pointer.pointerId !== drag.pointerId) return;
|
|
1075
|
+
if (!drag.active) {
|
|
1076
|
+
// A press and release without travel stays a plain click.
|
|
1077
|
+
const travelled =
|
|
1078
|
+
Math.abs(pointer.clientX - drag.x) > DRAG_THRESHOLD ||
|
|
1079
|
+
Math.abs(pointer.clientY - drag.y) > DRAG_THRESHOLD;
|
|
1080
|
+
if (!travelled) return;
|
|
1081
|
+
drag.active = true;
|
|
1082
|
+
// Capture only once the gesture is a drag. Capturing on the press would
|
|
1083
|
+
// retarget the trailing click to the calendar and swallow a plain pick
|
|
1084
|
+
// on the very cell that carries a handle.
|
|
1085
|
+
try {
|
|
1086
|
+
this._calendar?.setPointerCapture(drag.pointerId);
|
|
1087
|
+
} catch {
|
|
1088
|
+
// No active pointer for this id (synthetic event): move and up still
|
|
1089
|
+
// arrive through normal bubbling.
|
|
1090
|
+
}
|
|
1091
|
+
this._syncDragAffordance();
|
|
1092
|
+
}
|
|
1093
|
+
const date = this._dateAtPoint(pointer.clientX, pointer.clientY);
|
|
1094
|
+
// An unavailable cell is not a drop target: hold the last valid projection
|
|
1095
|
+
// instead of promising a move that would be refused.
|
|
1096
|
+
if (!date || this._calendar?.getDateState(date).disabled) return;
|
|
1097
|
+
drag.date = date;
|
|
1098
|
+
this._setPreview(date, drag.endpoint);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** @param {Event} event */
|
|
1102
|
+
_onGridPointerUp(event) {
|
|
1103
|
+
const pointer = /** @type {PointerEvent} */ (event);
|
|
1104
|
+
const drag = this._drag;
|
|
1105
|
+
if (!drag || pointer.pointerId !== drag.pointerId) return;
|
|
1106
|
+
this._drag = null;
|
|
1107
|
+
this._releaseDragCapture(drag.pointerId);
|
|
1108
|
+
if (!drag.active) {
|
|
1109
|
+
this._syncDragAffordance();
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
this._suppressGridClick = true;
|
|
1113
|
+
const transition = this._range?.projectEndpoint(drag.date, drag.endpoint, (bound) =>
|
|
1114
|
+
this._boundEditable(bound),
|
|
1115
|
+
);
|
|
1116
|
+
if (!transition || transition.status === "refused" || !transition.changedEndpoints.length) {
|
|
1117
|
+
this._syncHighlight();
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
// The drop goes through the same availability check and the same
|
|
1121
|
+
// cancelable `dateactivate` seam as a click, under the same generation
|
|
1122
|
+
// guard: a late response can never move a bound after the popup closed or
|
|
1123
|
+
// after a newer interaction.
|
|
1124
|
+
const target = drag.endpoint === "start" ? transition.range.start : transition.range.end;
|
|
1125
|
+
this._pendingIntents.clear();
|
|
1126
|
+
this._pendingIntents.set(target, { id: ++this._rangeCommitId, endpoint: drag.endpoint });
|
|
1127
|
+
void this._calendar?.activateDate(target);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** @param {Event} event */
|
|
1131
|
+
_onGridPointerCancel(event) {
|
|
1132
|
+
const pointer = /** @type {PointerEvent} */ (event);
|
|
1133
|
+
const drag = this._drag;
|
|
1134
|
+
if (!drag || pointer.pointerId !== drag.pointerId) return;
|
|
1135
|
+
this._drag = null;
|
|
1136
|
+
this._releaseDragCapture(drag.pointerId);
|
|
1137
|
+
// A cancelled gesture commits nothing and restores the committed band.
|
|
1138
|
+
this._syncHighlight();
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** @param {number} pointerId */
|
|
1142
|
+
_releaseDragCapture(pointerId) {
|
|
1143
|
+
const calendar = this._calendar;
|
|
1144
|
+
if (calendar?.hasPointerCapture(pointerId)) calendar.releasePointerCapture(pointerId);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** Expose whether the band currently offers draggable handles, and whether
|
|
1148
|
+
* one is being dragged, so the cursor can say so. */
|
|
1149
|
+
_syncDragAffordance() {
|
|
1150
|
+
const calendar = this._calendar;
|
|
1151
|
+
if (!calendar || !this._range) return;
|
|
1152
|
+
if (this._drag?.active) {
|
|
1153
|
+
calendar.setAttribute("data-range-drag", "active");
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const { start, end } = this._range;
|
|
1157
|
+
const draggable =
|
|
1158
|
+
this._range.complete && start !== end && (this._boundEditable("start") || this._boundEditable("end"));
|
|
1159
|
+
if (draggable) calendar.setAttribute("data-range-drag", "ready");
|
|
1160
|
+
else calendar.removeAttribute("data-range-drag");
|
|
874
1161
|
}
|
|
875
1162
|
|
|
876
1163
|
/** @param {KeyboardEvent} event */
|
|
@@ -890,6 +1177,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
890
1177
|
calendar.min = this.min;
|
|
891
1178
|
calendar.max = this.max;
|
|
892
1179
|
calendar.monthFormat = this.monthFormat;
|
|
1180
|
+
calendar.controlClasses = this._controlClasses;
|
|
893
1181
|
calendar.messages = this._messages;
|
|
894
1182
|
calendar.source = this._source;
|
|
895
1183
|
calendar.dateState = this._dateState;
|
|
@@ -963,7 +1251,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
963
1251
|
this._reflecting = false;
|
|
964
1252
|
}
|
|
965
1253
|
|
|
966
|
-
/** @param {string} value @param {{emit?:boolean,format?:boolean,reflect?:boolean}} [options] */
|
|
1254
|
+
/** @param {string} value @param {{emit?:boolean,format?:boolean,reflect?:boolean,moveDisplay?:boolean}} [options] */
|
|
967
1255
|
_setValue(value, options = {}) {
|
|
968
1256
|
if (value && !isDate(value)) throw new TypeError(`Invalid date-picker value: ${value}`);
|
|
969
1257
|
const previous = this._value;
|
|
@@ -972,7 +1260,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
972
1260
|
this._field?.setCanonical(this._value, { format: options.format !== false });
|
|
973
1261
|
if (this._calendar) {
|
|
974
1262
|
this._calendar.value = this._value;
|
|
975
|
-
|
|
1263
|
+
// A background revalidation keeps the user's visible month and keyboard
|
|
1264
|
+
// target; only an explicit commit re-homes them onto the value.
|
|
1265
|
+
if (this._value && options.moveDisplay !== false) this._calendar.focusedDate = this._value;
|
|
976
1266
|
}
|
|
977
1267
|
this._refreshButtonLabel();
|
|
978
1268
|
if (previous !== this._value) {
|
|
@@ -989,29 +1279,39 @@ export class DatePickerElement extends HTMLElement {
|
|
|
989
1279
|
* Calendar-backed availability gate injected into the field controllers.
|
|
990
1280
|
* A cancelled or failed source load confirms nothing: do not fill the
|
|
991
1281
|
* submitted ISO field nor treat the date as available.
|
|
992
|
-
* @param {string} date @
|
|
1282
|
+
* @param {string} date @param {{moveDisplay?: boolean}} [options]
|
|
1283
|
+
* @returns {Promise<{ok: boolean, message?: string}>}
|
|
993
1284
|
*/
|
|
994
|
-
async _confirmDate(date) {
|
|
1285
|
+
async _confirmDate(date, options = {}) {
|
|
995
1286
|
const calendar = this._calendar;
|
|
996
1287
|
if (!calendar) return { ok: true };
|
|
997
|
-
|
|
998
|
-
|
|
1288
|
+
// Only a user-initiated commit may re-home the visible month. A revalidation
|
|
1289
|
+
// (source reload, attribute change) must leave `display` where the user left
|
|
1290
|
+
// it, per the display/value separation contract.
|
|
1291
|
+
if (options.moveDisplay !== false) {
|
|
1292
|
+
const display = monthKey(date);
|
|
1293
|
+
if (calendar.display !== display) calendar.display = display;
|
|
1294
|
+
}
|
|
999
1295
|
const confirmed = await calendar.ensureDate(date);
|
|
1000
1296
|
const state = calendar.getDateState(date);
|
|
1001
1297
|
if (!confirmed || state.disabled) return { ok: false, message: this._messages.unavailableDate };
|
|
1002
1298
|
return { ok: true };
|
|
1003
1299
|
}
|
|
1004
1300
|
|
|
1005
|
-
/** @param {boolean} emit @param {{force?: boolean}} [options] */
|
|
1301
|
+
/** @param {boolean} emit @param {{force?: boolean, moveDisplay?: boolean}} [options] */
|
|
1006
1302
|
async _commitText(emit, options = {}) {
|
|
1007
1303
|
const field = this._field;
|
|
1008
1304
|
if (!field) return false;
|
|
1009
1305
|
// Blur/change skip untouched text; explicit validation must recheck the
|
|
1010
1306
|
// current value against the latest constraints regardless of dirtiness.
|
|
1011
1307
|
if (!options.force && !field.isDirty) return false;
|
|
1012
|
-
const result = await field.commit();
|
|
1308
|
+
const result = await field.commit(options);
|
|
1013
1309
|
if (result.status === "stale" || result.status === "invalid") return false;
|
|
1014
|
-
this._setValue(result.value || "", {
|
|
1310
|
+
this._setValue(result.value || "", {
|
|
1311
|
+
emit,
|
|
1312
|
+
format: result.status === "ok",
|
|
1313
|
+
moveDisplay: options.moveDisplay,
|
|
1314
|
+
});
|
|
1015
1315
|
return true;
|
|
1016
1316
|
}
|
|
1017
1317
|
|
|
@@ -1092,20 +1392,23 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1092
1392
|
this.dispatchEvent(new CustomEvent("rangechange", { detail: { ...this._range.range }, bubbles: true }));
|
|
1093
1393
|
}
|
|
1094
1394
|
|
|
1095
|
-
/**
|
|
1096
|
-
*
|
|
1395
|
+
/** Displayable projection of the business range. An inverted or start-less
|
|
1396
|
+
* pair shows no misleading band. @returns {{start:string, end:string}} */
|
|
1397
|
+
_displayRange() {
|
|
1398
|
+
const { start, end } = this._range?.range ?? { start: "", end: "" };
|
|
1399
|
+
if (!start) return { start: "", end: "" };
|
|
1400
|
+
if (end && compareDates(end, start) < 0) return { start, end: "" };
|
|
1401
|
+
return { start, end };
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** Put the committed range back on the calendar band, dropping any
|
|
1405
|
+
* projection currently shown over it. */
|
|
1097
1406
|
_syncHighlight() {
|
|
1098
1407
|
if (!this._calendar || !this._range) return;
|
|
1099
|
-
|
|
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
|
-
}
|
|
1408
|
+
this._previewDate = "";
|
|
1409
|
+
this._calendar.removeAttribute("data-range-preview");
|
|
1410
|
+
this._calendar.highlightedRange = this._displayRange();
|
|
1411
|
+
this._syncDragAffordance();
|
|
1109
1412
|
}
|
|
1110
1413
|
|
|
1111
1414
|
/**
|
|
@@ -1259,7 +1562,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1259
1562
|
// field controller, hidden ISO, range model and events stay coherent.
|
|
1260
1563
|
// Empty text flows through commit()'s "clear" outcome, not an early exit.
|
|
1261
1564
|
for (const [index, field] of fields.entries()) {
|
|
1262
|
-
const result = await field.commit();
|
|
1565
|
+
const result = await field.commit({ moveDisplay: false });
|
|
1263
1566
|
if (result.status === "ok" || result.status === "clear") {
|
|
1264
1567
|
const which = index === 1 ? "end" : "start";
|
|
1265
1568
|
this._setBound(which, result.value || "", { format: result.status === "ok" });
|
|
@@ -1274,7 +1577,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1274
1577
|
if (!input.value.trim()) {
|
|
1275
1578
|
input.setCustomValidity("");
|
|
1276
1579
|
} else {
|
|
1277
|
-
await this._commitText(false, { force: true });
|
|
1580
|
+
await this._commitText(false, { force: true, moveDisplay: false });
|
|
1278
1581
|
}
|
|
1279
1582
|
// Same start-then-end order as the range path, so a from/to inversion
|
|
1280
1583
|
// ends reported on "end". A programmatic time change resyncs here too.
|
|
@@ -1345,6 +1648,12 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1345
1648
|
this._setExpanded(false);
|
|
1346
1649
|
this._rangeCommitId++;
|
|
1347
1650
|
this._pendingIntents.clear();
|
|
1651
|
+
this._suppressGridClick = false;
|
|
1652
|
+
if (this._drag) {
|
|
1653
|
+
this._releaseDragCapture(this._drag.pointerId);
|
|
1654
|
+
this._drag = null;
|
|
1655
|
+
}
|
|
1656
|
+
this._syncHighlight();
|
|
1348
1657
|
if (restoreFocus) {
|
|
1349
1658
|
if (this._rangeMode()) {
|
|
1350
1659
|
// Range owns a workflow: after a first pick the active bound moved, so
|