@lekoala/date-picker 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -8
- package/custom-elements.json +32 -2
- package/dist/date-picker.css +163 -115
- package/dist/date-picker.js +656 -171
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +177 -129
- package/dist/types/date-calendar.d.ts +34 -3
- package/dist/types/date-calendar.d.ts.map +1 -1
- package/dist/types/date-field.d.ts +15 -5
- package/dist/types/date-field.d.ts.map +1 -1
- package/dist/types/date-picker.d.ts +153 -14
- package/dist/types/date-picker.d.ts.map +1 -1
- package/dist/types/date-range.d.ts +102 -20
- package/dist/types/date-range.d.ts.map +1 -1
- package/dist/types/date.d.ts +10 -0
- package/dist/types/date.d.ts.map +1 -1
- package/dist/types/intl.d.ts.map +1 -1
- package/docs/ACCESSIBILITY.md +22 -10
- package/docs/API.md +72 -3
- package/docs/ARCHITECTURE.md +16 -2
- package/docs/AT-CHECKLIST.md +1 -1
- package/docs/DECISIONS.md +19 -4
- package/docs/MIGRATION-FLATPICKR.md +2 -2
- package/docs/TESTING.md +1 -1
- package/docs/USE_CASES.md +3 -0
- package/package.json +7 -7
- package/src/date-calendar.js +148 -48
- package/src/date-field.js +20 -8
- package/src/date-picker.css +163 -115
- package/src/date-picker.js +549 -110
- package/src/date-range.js +184 -30
- package/src/date.js +50 -1
- package/src/intl.js +13 -7
package/src/date-picker.js
CHANGED
|
@@ -9,6 +9,9 @@ import { compareTimes, isTime } from "./time.js";
|
|
|
9
9
|
|
|
10
10
|
let uid = 0;
|
|
11
11
|
|
|
12
|
+
/** Pointer travel, in CSS pixels, that turns a press into an endpoint drag. */
|
|
13
|
+
const DRAG_THRESHOLD = 4;
|
|
14
|
+
|
|
12
15
|
export class DatePickerElement extends HTMLElement {
|
|
13
16
|
static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
|
|
14
17
|
|
|
@@ -33,10 +36,22 @@ export class DatePickerElement extends HTMLElement {
|
|
|
33
36
|
/** @type {"" | "start" | "end"} */
|
|
34
37
|
this._lastFocusEndpoint = "";
|
|
35
38
|
this._rangeCommitId = 0;
|
|
36
|
-
/**
|
|
37
|
-
* time (range mode supersession/stale protection)
|
|
39
|
+
/** Grid activation intents, keyed by date: the generation at interaction
|
|
40
|
+
* time (range mode supersession/stale protection) plus the bound a drag
|
|
41
|
+
* drop targets, empty for a normal pick.
|
|
42
|
+
* @type {Map<string, {id: number, endpoint: "" | "start" | "end"}>} */
|
|
38
43
|
this._pendingIntents = new Map();
|
|
39
|
-
|
|
44
|
+
/** Candidate date currently projected onto the calendar band, if any. */
|
|
45
|
+
this._previewDate = "";
|
|
46
|
+
/** Live endpoint drag, or null. @type {{pointerId:number, endpoint:"start"|"end", x:number, y:number, date:string, active:boolean} | null} */
|
|
47
|
+
this._drag = null;
|
|
48
|
+
/** A real drag already committed; swallow the click that follows it. */
|
|
49
|
+
this._suppressGridClick = false;
|
|
50
|
+
/** Overlaid calendar triggers, one per field (single) or per bound (range). @type {{button: HTMLButtonElement, endpoint: "" | "start" | "end"}[]} */
|
|
51
|
+
this._buttons = [];
|
|
52
|
+
/** Control that opened the popover, so single mode can restore focus to it.
|
|
53
|
+
* @type {HTMLElement | null} */
|
|
54
|
+
this._returnFocus = null;
|
|
40
55
|
this._panel = null;
|
|
41
56
|
this._calendar = null;
|
|
42
57
|
this._originalDescribedBy = "";
|
|
@@ -56,6 +71,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
56
71
|
this._renderDay = null;
|
|
57
72
|
/** @type {any} */
|
|
58
73
|
this._isDateDisabled = null;
|
|
74
|
+
/** Public picker coordinate-space request. @type {"viewport" | "document"} */
|
|
75
|
+
this._coordinateSpace = "viewport";
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
_rangeMode() {
|
|
@@ -84,7 +101,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
84
101
|
this._discoverTimeFields();
|
|
85
102
|
this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
|
|
86
103
|
this._field.onAttributesChanged = () => this._syncInputState();
|
|
87
|
-
this._field.confirm = (date) => this._confirmDate(date);
|
|
104
|
+
this._field.confirm = (date, options) => this._confirmDate(date, options);
|
|
88
105
|
this._setupFormValue();
|
|
89
106
|
this._build();
|
|
90
107
|
this._syncInputState();
|
|
@@ -162,10 +179,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
162
179
|
];
|
|
163
180
|
for (const field of this._fields) {
|
|
164
181
|
field.onAttributesChanged = () => this._syncRangeFields();
|
|
165
|
-
field.confirm = (date) => this._confirmDate(date);
|
|
182
|
+
field.confirm = (date, options) => this._confirmDate(date, options);
|
|
166
183
|
field.setupFormValue();
|
|
167
184
|
}
|
|
168
|
-
this._build(
|
|
185
|
+
this._build();
|
|
169
186
|
this._setRangeAria();
|
|
170
187
|
this._syncRangeFields();
|
|
171
188
|
this._bindRange();
|
|
@@ -204,10 +221,11 @@ export class DatePickerElement extends HTMLElement {
|
|
|
204
221
|
this._teardownField(this._field);
|
|
205
222
|
}
|
|
206
223
|
this._field = null;
|
|
207
|
-
this.
|
|
224
|
+
for (const { button } of this._buttons) button.remove();
|
|
225
|
+
this._buttons = [];
|
|
226
|
+
this._returnFocus = null;
|
|
208
227
|
this._panel?.remove();
|
|
209
228
|
this._formatHint?.remove();
|
|
210
|
-
this._button = null;
|
|
211
229
|
this._panel = null;
|
|
212
230
|
this._calendar = null;
|
|
213
231
|
this._formatHint = null;
|
|
@@ -392,6 +410,15 @@ export class DatePickerElement extends HTMLElement {
|
|
|
392
410
|
if (this._connected) void this.validate();
|
|
393
411
|
}
|
|
394
412
|
|
|
413
|
+
/** @public JS-only coordinate-space policy: "viewport" by default, "document" as an explicit opt-in; a change made while open applies to the next opening only. @returns {"viewport" | "document"} */
|
|
414
|
+
get coordinateSpace() {
|
|
415
|
+
return this._coordinateSpace;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
set coordinateSpace(value) {
|
|
419
|
+
this._coordinateSpace = value === "document" ? "document" : "viewport";
|
|
420
|
+
}
|
|
421
|
+
|
|
395
422
|
_adapter() {
|
|
396
423
|
return this._field?.adapter ?? this._fields?.[0]?.adapter ?? createDateAdapter(this.locale);
|
|
397
424
|
}
|
|
@@ -406,16 +433,23 @@ export class DatePickerElement extends HTMLElement {
|
|
|
406
433
|
// The hidden canonical field owns submission; dynamic name/form/disabled
|
|
407
434
|
// movement lives in the field controller.
|
|
408
435
|
this._field?.syncInputState();
|
|
409
|
-
|
|
436
|
+
const button = this._buttons[0]?.button;
|
|
437
|
+
if (button) button.disabled = input.disabled || input.readOnly;
|
|
410
438
|
if (this._open && (input.disabled || input.readOnly)) this.hide(false);
|
|
411
439
|
}
|
|
412
440
|
|
|
413
441
|
_syncRangeFields() {
|
|
414
442
|
if (!this._fields) return;
|
|
415
443
|
for (const field of this._fields) field.syncInputState();
|
|
444
|
+
// Each trigger follows its own bound: a disabled end never disables the
|
|
445
|
+
// start trigger.
|
|
446
|
+
for (const { button, endpoint } of this._buttons) {
|
|
447
|
+
const index = endpoint === "end" ? 1 : 0;
|
|
448
|
+
const field = this._fields[index];
|
|
449
|
+
button.disabled = Boolean(field?.input.disabled || field?.input.readOnly);
|
|
450
|
+
}
|
|
451
|
+
this._syncDragAffordance();
|
|
416
452
|
const active = this._range?.activeEndpoint === "end" ? 1 : 0;
|
|
417
|
-
const disabled = this._fields.every((field) => field.input.disabled || field.input.readOnly);
|
|
418
|
-
if (this._button) this._button.disabled = disabled;
|
|
419
453
|
if (this._open && (this._fields[active]?.input.disabled || this._fields[active]?.input.readOnly)) {
|
|
420
454
|
this.hide(false);
|
|
421
455
|
}
|
|
@@ -436,20 +470,52 @@ export class DatePickerElement extends HTMLElement {
|
|
|
436
470
|
}
|
|
437
471
|
}
|
|
438
472
|
|
|
439
|
-
/**
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
473
|
+
/**
|
|
474
|
+
* Create one calendar trigger. It is a real button overlaid inside the
|
|
475
|
+
* field's own box (see the CSS): the field reserves the room, the trigger
|
|
476
|
+
* stays transparent, and the field's focus ring wraps both.
|
|
477
|
+
* @param {"" | "start" | "end"} endpoint
|
|
478
|
+
* @returns {HTMLButtonElement}
|
|
479
|
+
*/
|
|
480
|
+
_createTrigger(endpoint) {
|
|
445
481
|
const button = document.createElement("button");
|
|
446
482
|
button.type = "button";
|
|
447
483
|
button.className = "dp-picker-button";
|
|
448
484
|
button.setAttribute("aria-haspopup", "dialog");
|
|
449
485
|
button.setAttribute("aria-expanded", "false");
|
|
450
|
-
button.setAttribute("aria-controls",
|
|
486
|
+
button.setAttribute("aria-controls", `${this._id}-panel`);
|
|
487
|
+
if (endpoint) button.dataset.endpoint = endpoint;
|
|
451
488
|
button.innerHTML =
|
|
452
489
|
'<span aria-hidden="true"><svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><rect x="1.75" y="3" width="12.5" height="11" rx="1.75"/><path d="M1.75 6.75h12.5"/><path d="M5.25 1.75v2.5M10.75 1.75v2.5"/></svg></span>';
|
|
490
|
+
return button;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Build the popup once and one trigger per field. Each trigger is inserted
|
|
495
|
+
* immediately after its own field so DOM order follows visual/tab order
|
|
496
|
+
* (`date -> its trigger -> next control`). The hidden ISO field may sit
|
|
497
|
+
* between the two; it is not focusable.
|
|
498
|
+
*/
|
|
499
|
+
_build() {
|
|
500
|
+
const ranges = this._rangeMode();
|
|
501
|
+
/** @type {[HTMLInputElement, "" | "start" | "end"][]} */
|
|
502
|
+
const targets = [];
|
|
503
|
+
if (ranges) {
|
|
504
|
+
for (const [index, field] of (this._fields || []).entries()) {
|
|
505
|
+
targets.push([field.input, index === 1 ? "end" : "start"]);
|
|
506
|
+
}
|
|
507
|
+
} else if (this._input) {
|
|
508
|
+
targets.push([this._input, ""]);
|
|
509
|
+
}
|
|
510
|
+
if (!targets.length) return;
|
|
511
|
+
const panelId = `${this._id}-panel`;
|
|
512
|
+
const hintId = `${this._id}-format`;
|
|
513
|
+
|
|
514
|
+
this._buttons = targets.map(([input, endpoint]) => {
|
|
515
|
+
const button = this._createTrigger(endpoint);
|
|
516
|
+
input.insertAdjacentElement("afterend", button);
|
|
517
|
+
return { button, endpoint };
|
|
518
|
+
});
|
|
453
519
|
|
|
454
520
|
const panel = document.createElement("div");
|
|
455
521
|
panel.id = panelId;
|
|
@@ -467,23 +533,21 @@ export class DatePickerElement extends HTMLElement {
|
|
|
467
533
|
hint.id = hintId;
|
|
468
534
|
hint.className = "dp-visually-hidden";
|
|
469
535
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
panel.insertAdjacentElement("afterend", hint);
|
|
536
|
+
// Out of flow (popover / visually hidden): placement is irrelevant.
|
|
537
|
+
this.append(panel, hint);
|
|
473
538
|
|
|
474
|
-
this._button = button;
|
|
475
539
|
this._panel = panel;
|
|
476
540
|
this._calendar = calendar;
|
|
477
541
|
this._formatHint = hint;
|
|
478
542
|
|
|
479
|
-
if (!this.
|
|
480
|
-
this._originalDescribedBy =
|
|
543
|
+
if (!ranges && this._input) {
|
|
544
|
+
this._originalDescribedBy = this._input.getAttribute("aria-describedby") || "";
|
|
481
545
|
const describedBy = [this._originalDescribedBy, hintId].filter(Boolean).join(" ");
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
546
|
+
this._input.setAttribute("aria-describedby", describedBy);
|
|
547
|
+
this._input.setAttribute("role", "combobox");
|
|
548
|
+
this._input.setAttribute("aria-haspopup", "dialog");
|
|
549
|
+
this._input.setAttribute("aria-expanded", "false");
|
|
550
|
+
this._input.setAttribute("aria-controls", panelId);
|
|
487
551
|
}
|
|
488
552
|
this._refreshLocale();
|
|
489
553
|
this._refreshButtonLabel();
|
|
@@ -491,7 +555,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
491
555
|
|
|
492
556
|
_bind() {
|
|
493
557
|
const input = this._input;
|
|
494
|
-
const button = this.
|
|
558
|
+
const button = this._buttons[0]?.button;
|
|
495
559
|
const calendar = this._calendar;
|
|
496
560
|
if (!input || !button || !calendar) return;
|
|
497
561
|
const controller = new AbortController();
|
|
@@ -550,10 +614,11 @@ export class DatePickerElement extends HTMLElement {
|
|
|
550
614
|
this.hide(false);
|
|
551
615
|
return;
|
|
552
616
|
}
|
|
553
|
-
this.show();
|
|
617
|
+
this.show({ returnFocus: button });
|
|
554
618
|
},
|
|
555
619
|
{ signal },
|
|
556
620
|
);
|
|
621
|
+
button.addEventListener("keydown", (event) => this._onTriggerKeyDown("", event), { signal });
|
|
557
622
|
calendar.addEventListener(
|
|
558
623
|
"dateactivate",
|
|
559
624
|
(event) => {
|
|
@@ -568,11 +633,13 @@ export class DatePickerElement extends HTMLElement {
|
|
|
568
633
|
this._field?.dirty();
|
|
569
634
|
this._setValue(date, { emit: true, format: true });
|
|
570
635
|
this.hide(false);
|
|
571
|
-
this.
|
|
636
|
+
this._restoreFocus();
|
|
572
637
|
});
|
|
573
638
|
},
|
|
574
639
|
{ signal },
|
|
575
640
|
);
|
|
641
|
+
// Re-check availability after a source load. validate() commits with
|
|
642
|
+
// moveDisplay:false, so the visible month stays where the user navigated.
|
|
576
643
|
calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
|
|
577
644
|
this.ownerDocument.addEventListener(
|
|
578
645
|
"pointerdown",
|
|
@@ -601,9 +668,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
601
668
|
}
|
|
602
669
|
|
|
603
670
|
_bindRange() {
|
|
604
|
-
const button = this._button;
|
|
605
671
|
const calendar = this._calendar;
|
|
606
|
-
if (!this._fields || !
|
|
672
|
+
if (!this._fields || !this._buttons.length || !calendar) return;
|
|
607
673
|
const controller = new AbortController();
|
|
608
674
|
this._controller = controller;
|
|
609
675
|
const { signal } = controller;
|
|
@@ -620,26 +686,35 @@ export class DatePickerElement extends HTMLElement {
|
|
|
620
686
|
);
|
|
621
687
|
field.input.addEventListener("keydown", (event) => this._onFieldKeyDown(endpoint, event), { signal });
|
|
622
688
|
}
|
|
623
|
-
|
|
689
|
+
for (const entry of this._buttons) {
|
|
690
|
+
const endpoint = entry.endpoint;
|
|
691
|
+
if (endpoint === "") continue;
|
|
692
|
+
entry.button.addEventListener("click", (event) => this._onRangeTriggerClick(endpoint, event), {
|
|
693
|
+
signal,
|
|
694
|
+
});
|
|
695
|
+
entry.button.addEventListener("keydown", (event) => this._onTriggerKeyDown(endpoint, event), {
|
|
696
|
+
signal,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
// Record grid-activation intent synchronously (click or Enter/Space). The
|
|
700
|
+
// picker only commits a dateactivate whose generation still matches, so a
|
|
701
|
+
// response that resolves after the active endpoint changed (or after a
|
|
702
|
+
// newer activation) is dropped without touching either bound.
|
|
703
|
+
calendar.addEventListener(
|
|
624
704
|
"click",
|
|
625
705
|
(event) => {
|
|
626
|
-
if (this.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
706
|
+
if (this._suppressGridClick) {
|
|
707
|
+
// The gesture already committed through the drop path; this click is
|
|
708
|
+
// only the tail of the drag and must not activate a second time.
|
|
709
|
+
this._suppressGridClick = false;
|
|
710
|
+
event.preventDefault();
|
|
711
|
+
event.stopImmediatePropagation();
|
|
632
712
|
return;
|
|
633
713
|
}
|
|
634
|
-
this.
|
|
714
|
+
this._captureGridIntent(event);
|
|
635
715
|
},
|
|
636
|
-
{ signal },
|
|
716
|
+
{ capture: true, signal },
|
|
637
717
|
);
|
|
638
|
-
// Record grid-activation intent synchronously (click or Enter/Space). The
|
|
639
|
-
// picker only commits a dateactivate whose generation still matches, so a
|
|
640
|
-
// response that resolves after the active endpoint changed (or after a
|
|
641
|
-
// newer activation) is dropped without touching either bound.
|
|
642
|
-
calendar.addEventListener("click", (event) => this._captureGridIntent(event), { capture: true, signal });
|
|
643
718
|
calendar.addEventListener("keydown", (event) => this._captureGridIntent(event), {
|
|
644
719
|
capture: true,
|
|
645
720
|
signal,
|
|
@@ -652,16 +727,17 @@ export class DatePickerElement extends HTMLElement {
|
|
|
652
727
|
if (!isDate(date)) return;
|
|
653
728
|
queueMicrotask(() => {
|
|
654
729
|
if (custom.defaultPrevented || !this._connected || !this._range) return;
|
|
655
|
-
const
|
|
730
|
+
const intent = this._pendingIntents.get(date);
|
|
656
731
|
// A response is dropped when the active endpoint changed, the popup
|
|
657
732
|
// closed or a newer activation superseded this one.
|
|
658
|
-
if (
|
|
733
|
+
if (intent === undefined || intent.id !== this._rangeCommitId) return;
|
|
659
734
|
this._pendingIntents.delete(date);
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
735
|
+
const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
|
|
736
|
+
// Click, keyboard and drop share this one machine; only the entry
|
|
737
|
+
// point differs, a drag naming the handle it grabbed.
|
|
738
|
+
const result = intent.endpoint
|
|
739
|
+
? this._range.moveEndpoint(date, intent.endpoint, editable)
|
|
740
|
+
: this._range.activate(date, editable);
|
|
665
741
|
if (result.status === "refused") {
|
|
666
742
|
this._calendar?.dispatchEvent(
|
|
667
743
|
new CustomEvent("dateinvalid", {
|
|
@@ -671,7 +747,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
671
747
|
);
|
|
672
748
|
return;
|
|
673
749
|
}
|
|
674
|
-
this.
|
|
750
|
+
this._applyRangeTransition(result);
|
|
751
|
+
// A drop adjusts an existing range rather than validating it, so the
|
|
752
|
+
// popup stays open for the next adjustment.
|
|
753
|
+
if (intent.endpoint) return;
|
|
675
754
|
if (result.close || result.status === "complete") {
|
|
676
755
|
this.hide(false);
|
|
677
756
|
this._focusField(result.endpoint);
|
|
@@ -680,6 +759,18 @@ export class DatePickerElement extends HTMLElement {
|
|
|
680
759
|
},
|
|
681
760
|
{ signal },
|
|
682
761
|
);
|
|
762
|
+
// Preview seams. Hover and keyboard only supply a candidate date; the range
|
|
763
|
+
// machine decides what that candidate would produce.
|
|
764
|
+
calendar.addEventListener("datefocus", (event) => this._onGridDateFocus(event), { signal });
|
|
765
|
+
calendar.addEventListener("pointerover", (event) => this._onGridPointerOver(event), { signal });
|
|
766
|
+
calendar.addEventListener("pointerleave", () => this._onGridPointerLeave(), { signal });
|
|
767
|
+
calendar.addEventListener("pointerdown", (event) => this._onGridPointerDown(event), { signal });
|
|
768
|
+
calendar.addEventListener("pointermove", (event) => this._onGridPointerMove(event), { signal });
|
|
769
|
+
calendar.addEventListener("pointerup", (event) => this._onGridPointerUp(event), { signal });
|
|
770
|
+
calendar.addEventListener("pointercancel", (event) => this._onGridPointerCancel(event), { signal });
|
|
771
|
+
calendar.addEventListener("lostpointercapture", (event) => this._onGridPointerCancel(event), { signal });
|
|
772
|
+
// Re-check availability after a source load. validate() commits with
|
|
773
|
+
// moveDisplay:false, so the visible month stays where the user navigated.
|
|
683
774
|
calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
|
|
684
775
|
this.ownerDocument.addEventListener(
|
|
685
776
|
"pointerdown",
|
|
@@ -705,9 +796,75 @@ export class DatePickerElement extends HTMLElement {
|
|
|
705
796
|
this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
|
|
706
797
|
}
|
|
707
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Point the shared calendar at one range bound and invalidate any pending
|
|
801
|
+
* activation, so a late response can never commit on the wrong bound.
|
|
802
|
+
* @param {"start" | "end"} endpoint
|
|
803
|
+
*/
|
|
804
|
+
_activateRangeEndpoint(endpoint) {
|
|
805
|
+
const calendar = this._calendar;
|
|
806
|
+
if (!this._range || !calendar) return;
|
|
807
|
+
this._range.focus(endpoint);
|
|
808
|
+
this._rangeCommitId++;
|
|
809
|
+
this._pendingIntents.clear();
|
|
810
|
+
this._lastFocusEndpoint = endpoint;
|
|
811
|
+
const target = this._boundCanonical(endpoint) || todayISO();
|
|
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.
|
|
815
|
+
calendar.focusedDate = target;
|
|
816
|
+
this._clearPreview();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Range has one trigger per bound. A press on the other bound's trigger is a
|
|
821
|
+
* local switch, not a toggle: it repoints the single open popup instead of
|
|
822
|
+
* closing it. Only a press on the already-active bound toggles shut.
|
|
823
|
+
* @param {"start" | "end"} endpoint
|
|
824
|
+
* @param {MouseEvent} event
|
|
825
|
+
*/
|
|
826
|
+
_onRangeTriggerClick(endpoint, event) {
|
|
827
|
+
if (!this._open) {
|
|
828
|
+
this.show({ endpoint });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (event.detail === 0) {
|
|
832
|
+
// Keyboard activation of an already-open picker: switch the bound, then
|
|
833
|
+
// move into the grid. Escape keeps closing it.
|
|
834
|
+
this._activateRangeEndpoint(endpoint);
|
|
835
|
+
setTimeout(() => this._calendar?.focusGrid(), 0);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (endpoint !== this._range?.activeEndpoint) {
|
|
839
|
+
this._activateRangeEndpoint(endpoint);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
this.hide(false);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* ArrowDown on a trigger opens the popup and drops into the grid, like the
|
|
847
|
+
* field itself (Enter/Space go through the click path). Needed because focus
|
|
848
|
+
* now returns to the trigger that opened the popover.
|
|
849
|
+
* @param {"" | "start" | "end"} endpoint
|
|
850
|
+
* @param {KeyboardEvent} event
|
|
851
|
+
*/
|
|
852
|
+
_onTriggerKeyDown(endpoint, event) {
|
|
853
|
+
if (event.key !== "ArrowDown") return;
|
|
854
|
+
event.preventDefault();
|
|
855
|
+
if (this._open) {
|
|
856
|
+
if (this._rangeMode() && endpoint) this._activateRangeEndpoint(endpoint);
|
|
857
|
+
// Chromium drops script-initiated focus changes during keydown dispatch.
|
|
858
|
+
setTimeout(() => this._calendar?.focusGrid(), 0);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (this._rangeMode()) this.show({ endpoint: endpoint || undefined });
|
|
862
|
+
else this.show({ returnFocus: this._buttons[0]?.button });
|
|
863
|
+
}
|
|
864
|
+
|
|
708
865
|
/** @param {"" | "start" | "end"} endpoint @param {KeyboardEvent} event */
|
|
709
866
|
_onFieldKeyDown(endpoint, event) {
|
|
710
|
-
if (event.key === "ArrowDown"
|
|
867
|
+
if (event.key === "ArrowDown") {
|
|
711
868
|
event.preventDefault();
|
|
712
869
|
if (this._rangeMode() && endpoint) {
|
|
713
870
|
this._lastFocusEndpoint = endpoint;
|
|
@@ -758,7 +915,249 @@ export class DatePickerElement extends HTMLElement {
|
|
|
758
915
|
const cell = event.target.closest(".dp-day[data-date]");
|
|
759
916
|
const date = cell?.getAttribute("data-date") || "";
|
|
760
917
|
if (!isDate(date)) return;
|
|
761
|
-
|
|
918
|
+
// Each activation supersedes every earlier pending one.
|
|
919
|
+
this._pendingIntents.clear();
|
|
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");
|
|
762
1161
|
}
|
|
763
1162
|
|
|
764
1163
|
/** @param {KeyboardEvent} event */
|
|
@@ -818,25 +1217,29 @@ export class DatePickerElement extends HTMLElement {
|
|
|
818
1217
|
}
|
|
819
1218
|
|
|
820
1219
|
_refreshButtonLabel() {
|
|
821
|
-
|
|
1220
|
+
const single = this._buttons[0]?.button;
|
|
1221
|
+
if (!single) return;
|
|
822
1222
|
if (this._rangeMode()) {
|
|
823
1223
|
this._refreshRangeButtonLabel();
|
|
824
1224
|
return;
|
|
825
1225
|
}
|
|
826
1226
|
const prefix = this._value ? this._messages.changeDate : this._messages.chooseDate;
|
|
827
1227
|
const suffix = this._value ? `, ${formatLongDate(this._value, this.locale)}` : "";
|
|
828
|
-
|
|
1228
|
+
single.setAttribute("aria-label", `${prefix}${suffix}`);
|
|
829
1229
|
}
|
|
830
1230
|
|
|
1231
|
+
/** Each bound has its own trigger, so each needs its own accessible name: the
|
|
1232
|
+
* bound label is what tells two otherwise identical triggers apart. */
|
|
831
1233
|
_refreshRangeButtonLabel() {
|
|
832
|
-
if (!this.
|
|
1234
|
+
if (!this._range) return;
|
|
833
1235
|
const { start, end } = this._range;
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
? `, ${formatLongDate(
|
|
838
|
-
|
|
839
|
-
|
|
1236
|
+
for (const { button, endpoint } of this._buttons) {
|
|
1237
|
+
const bound = endpoint === "end" ? end : start;
|
|
1238
|
+
const prefix = bound ? this._messages.changeDate : this._messages.chooseDate;
|
|
1239
|
+
const suffix = bound ? `, ${formatLongDate(bound, this.locale)}` : "";
|
|
1240
|
+
const boundLabel = endpoint === "end" ? this._messages.rangeEnd : this._messages.rangeStart;
|
|
1241
|
+
button.setAttribute("aria-label", `${prefix}${suffix}, ${boundLabel}`);
|
|
1242
|
+
}
|
|
840
1243
|
}
|
|
841
1244
|
|
|
842
1245
|
/** @param {string} value */
|
|
@@ -847,7 +1250,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
847
1250
|
this._reflecting = false;
|
|
848
1251
|
}
|
|
849
1252
|
|
|
850
|
-
/** @param {string} value @param {{emit?:boolean,format?:boolean,reflect?:boolean}} [options] */
|
|
1253
|
+
/** @param {string} value @param {{emit?:boolean,format?:boolean,reflect?:boolean,moveDisplay?:boolean}} [options] */
|
|
851
1254
|
_setValue(value, options = {}) {
|
|
852
1255
|
if (value && !isDate(value)) throw new TypeError(`Invalid date-picker value: ${value}`);
|
|
853
1256
|
const previous = this._value;
|
|
@@ -856,7 +1259,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
856
1259
|
this._field?.setCanonical(this._value, { format: options.format !== false });
|
|
857
1260
|
if (this._calendar) {
|
|
858
1261
|
this._calendar.value = this._value;
|
|
859
|
-
|
|
1262
|
+
// A background revalidation keeps the user's visible month and keyboard
|
|
1263
|
+
// target; only an explicit commit re-homes them onto the value.
|
|
1264
|
+
if (this._value && options.moveDisplay !== false) this._calendar.focusedDate = this._value;
|
|
860
1265
|
}
|
|
861
1266
|
this._refreshButtonLabel();
|
|
862
1267
|
if (previous !== this._value) {
|
|
@@ -873,29 +1278,39 @@ export class DatePickerElement extends HTMLElement {
|
|
|
873
1278
|
* Calendar-backed availability gate injected into the field controllers.
|
|
874
1279
|
* A cancelled or failed source load confirms nothing: do not fill the
|
|
875
1280
|
* submitted ISO field nor treat the date as available.
|
|
876
|
-
* @param {string} date @
|
|
1281
|
+
* @param {string} date @param {{moveDisplay?: boolean}} [options]
|
|
1282
|
+
* @returns {Promise<{ok: boolean, message?: string}>}
|
|
877
1283
|
*/
|
|
878
|
-
async _confirmDate(date) {
|
|
1284
|
+
async _confirmDate(date, options = {}) {
|
|
879
1285
|
const calendar = this._calendar;
|
|
880
1286
|
if (!calendar) return { ok: true };
|
|
881
|
-
|
|
882
|
-
|
|
1287
|
+
// Only a user-initiated commit may re-home the visible month. A revalidation
|
|
1288
|
+
// (source reload, attribute change) must leave `display` where the user left
|
|
1289
|
+
// it, per the display/value separation contract.
|
|
1290
|
+
if (options.moveDisplay !== false) {
|
|
1291
|
+
const display = monthKey(date);
|
|
1292
|
+
if (calendar.display !== display) calendar.display = display;
|
|
1293
|
+
}
|
|
883
1294
|
const confirmed = await calendar.ensureDate(date);
|
|
884
1295
|
const state = calendar.getDateState(date);
|
|
885
1296
|
if (!confirmed || state.disabled) return { ok: false, message: this._messages.unavailableDate };
|
|
886
1297
|
return { ok: true };
|
|
887
1298
|
}
|
|
888
1299
|
|
|
889
|
-
/** @param {boolean} emit @param {{force?: boolean}} [options] */
|
|
1300
|
+
/** @param {boolean} emit @param {{force?: boolean, moveDisplay?: boolean}} [options] */
|
|
890
1301
|
async _commitText(emit, options = {}) {
|
|
891
1302
|
const field = this._field;
|
|
892
1303
|
if (!field) return false;
|
|
893
1304
|
// Blur/change skip untouched text; explicit validation must recheck the
|
|
894
1305
|
// current value against the latest constraints regardless of dirtiness.
|
|
895
1306
|
if (!options.force && !field.isDirty) return false;
|
|
896
|
-
const result = await field.commit();
|
|
1307
|
+
const result = await field.commit(options);
|
|
897
1308
|
if (result.status === "stale" || result.status === "invalid") return false;
|
|
898
|
-
this._setValue(result.value || "", {
|
|
1309
|
+
this._setValue(result.value || "", {
|
|
1310
|
+
emit,
|
|
1311
|
+
format: result.status === "ok",
|
|
1312
|
+
moveDisplay: options.moveDisplay,
|
|
1313
|
+
});
|
|
899
1314
|
return true;
|
|
900
1315
|
}
|
|
901
1316
|
|
|
@@ -912,7 +1327,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
912
1327
|
this._orderTags.delete(index);
|
|
913
1328
|
return false;
|
|
914
1329
|
}
|
|
915
|
-
|
|
1330
|
+
// Typed commits keep native input/change only; synthetic events are
|
|
1331
|
+
// reserved for calendar-driven changes (no native input event occurred).
|
|
1332
|
+
this._setBound(which, result.value || "", { emit: false, user: true, format: result.status === "ok" });
|
|
916
1333
|
return true;
|
|
917
1334
|
}
|
|
918
1335
|
|
|
@@ -974,20 +1391,23 @@ export class DatePickerElement extends HTMLElement {
|
|
|
974
1391
|
this.dispatchEvent(new CustomEvent("rangechange", { detail: { ...this._range.range }, bubbles: true }));
|
|
975
1392
|
}
|
|
976
1393
|
|
|
977
|
-
/**
|
|
978
|
-
*
|
|
1394
|
+
/** Displayable projection of the business range. An inverted or start-less
|
|
1395
|
+
* pair shows no misleading band. @returns {{start:string, end:string}} */
|
|
1396
|
+
_displayRange() {
|
|
1397
|
+
const { start, end } = this._range?.range ?? { start: "", end: "" };
|
|
1398
|
+
if (!start) return { start: "", end: "" };
|
|
1399
|
+
if (end && compareDates(end, start) < 0) return { start, end: "" };
|
|
1400
|
+
return { start, end };
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** Put the committed range back on the calendar band, dropping any
|
|
1404
|
+
* projection currently shown over it. */
|
|
979
1405
|
_syncHighlight() {
|
|
980
1406
|
if (!this._calendar || !this._range) return;
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
}
|
|
986
|
-
if (end && compareDates(end, start) < 0) {
|
|
987
|
-
this._calendar.highlightedRange = { start, end: "" };
|
|
988
|
-
} else {
|
|
989
|
-
this._calendar.highlightedRange = { start, end };
|
|
990
|
-
}
|
|
1407
|
+
this._previewDate = "";
|
|
1408
|
+
this._calendar.removeAttribute("data-range-preview");
|
|
1409
|
+
this._calendar.highlightedRange = this._displayRange();
|
|
1410
|
+
this._syncDragAffordance();
|
|
991
1411
|
}
|
|
992
1412
|
|
|
993
1413
|
/**
|
|
@@ -1099,13 +1519,19 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1099
1519
|
}, 0);
|
|
1100
1520
|
}
|
|
1101
1521
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1522
|
+
/**
|
|
1523
|
+
* Restore focus to the control that opened the popover (single mode). focus()
|
|
1524
|
+
* must not run synchronously from a keydown handler (Chromium drops focus
|
|
1525
|
+
* changes there) and must not re-open the popover under the default
|
|
1526
|
+
* open-on-focus. Deferring keeps _suppressFocusOpen active for the call.
|
|
1527
|
+
*/
|
|
1528
|
+
_restoreFocus() {
|
|
1529
|
+
const target = this._returnFocus;
|
|
1530
|
+
this._returnFocus = null;
|
|
1531
|
+
if (!(target instanceof HTMLElement) || !target.isConnected || target.matches(":disabled")) return;
|
|
1106
1532
|
this._suppressFocusOpen = true;
|
|
1107
1533
|
setTimeout(() => {
|
|
1108
|
-
|
|
1534
|
+
target.focus();
|
|
1109
1535
|
this._suppressFocusOpen = false;
|
|
1110
1536
|
}, 0);
|
|
1111
1537
|
}
|
|
@@ -1135,7 +1561,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1135
1561
|
// field controller, hidden ISO, range model and events stay coherent.
|
|
1136
1562
|
// Empty text flows through commit()'s "clear" outcome, not an early exit.
|
|
1137
1563
|
for (const [index, field] of fields.entries()) {
|
|
1138
|
-
const result = await field.commit();
|
|
1564
|
+
const result = await field.commit({ moveDisplay: false });
|
|
1139
1565
|
if (result.status === "ok" || result.status === "clear") {
|
|
1140
1566
|
const which = index === 1 ? "end" : "start";
|
|
1141
1567
|
this._setBound(which, result.value || "", { format: result.status === "ok" });
|
|
@@ -1150,7 +1576,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1150
1576
|
if (!input.value.trim()) {
|
|
1151
1577
|
input.setCustomValidity("");
|
|
1152
1578
|
} else {
|
|
1153
|
-
await this._commitText(false, { force: true });
|
|
1579
|
+
await this._commitText(false, { force: true, moveDisplay: false });
|
|
1154
1580
|
}
|
|
1155
1581
|
// Same start-then-end order as the range path, so a from/to inversion
|
|
1156
1582
|
// ends reported on "end". A programmatic time change resyncs here too.
|
|
@@ -1160,34 +1586,39 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1160
1586
|
return input.checkValidity() && timesValid;
|
|
1161
1587
|
}
|
|
1162
1588
|
|
|
1163
|
-
/** @public @param {{moveFocus?:boolean}} [options] */
|
|
1589
|
+
/** @public @param {{moveFocus?:boolean, endpoint?:"start"|"end", returnFocus?:HTMLElement}} [options] */
|
|
1164
1590
|
show(options = {}) {
|
|
1165
1591
|
const panel = this._panel;
|
|
1166
1592
|
const calendar = this._calendar;
|
|
1167
|
-
|
|
1168
|
-
if (!panel || !calendar || !button || this._open) return;
|
|
1593
|
+
if (!panel || !calendar || !this._buttons.length || this._open) return;
|
|
1169
1594
|
if (this._rangeMode()) {
|
|
1170
|
-
const endpoint = this._resolveRangeEndpoint();
|
|
1595
|
+
const endpoint = options.endpoint || this._resolveRangeEndpoint();
|
|
1171
1596
|
if (!endpoint) return;
|
|
1172
|
-
this.
|
|
1173
|
-
this._rangeCommitId++;
|
|
1174
|
-
this._pendingIntents.clear();
|
|
1175
|
-
const target = this._boundCanonical(endpoint) || todayISO();
|
|
1176
|
-
calendar.display = monthKey(target);
|
|
1177
|
-
calendar.focusedDate = target;
|
|
1178
|
-
this._lastFocusEndpoint = endpoint;
|
|
1597
|
+
this._activateRangeEndpoint(endpoint);
|
|
1179
1598
|
} else {
|
|
1180
1599
|
const input = this._input;
|
|
1181
1600
|
if (!input || input.disabled || input.readOnly) return;
|
|
1182
1601
|
const target = this._value || this._adapter().parse(input.value) || todayISO();
|
|
1183
1602
|
calendar.display = monthKey(target);
|
|
1184
1603
|
calendar.focusedDate = target;
|
|
1604
|
+
// Popover convention: closing returns focus to the control that opened.
|
|
1605
|
+
this._returnFocus = options.returnFocus instanceof HTMLElement ? options.returnFocus : input;
|
|
1185
1606
|
}
|
|
1186
1607
|
panel.showPopover();
|
|
1187
1608
|
this._open = true;
|
|
1188
1609
|
this._setExpanded(true);
|
|
1610
|
+
// Freeze the explicitly configured coordinate space once per opening: a
|
|
1611
|
+
// change made while open takes effect on the next opening, never the
|
|
1612
|
+
// current one.
|
|
1613
|
+
const coordinateSpace = this.coordinateSpace;
|
|
1614
|
+
panel.style.position = coordinateSpace === "document" ? "absolute" : "fixed";
|
|
1189
1615
|
const position = () =>
|
|
1190
|
-
reposition(this, panel, {
|
|
1616
|
+
reposition(this, panel, {
|
|
1617
|
+
placement: "bottom-start",
|
|
1618
|
+
distance: 4,
|
|
1619
|
+
shiftPadding: 8,
|
|
1620
|
+
coordinateSpace,
|
|
1621
|
+
});
|
|
1191
1622
|
position();
|
|
1192
1623
|
this._stopTracking = autoUpdate(this, panel, position);
|
|
1193
1624
|
if (options.moveFocus !== false) queueMicrotask(() => calendar.focusGrid());
|
|
@@ -1198,7 +1629,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1198
1629
|
_setExpanded(expanded) {
|
|
1199
1630
|
const value = expanded ? "true" : "false";
|
|
1200
1631
|
this._input?.setAttribute("aria-expanded", value);
|
|
1201
|
-
this.
|
|
1632
|
+
for (const { button } of this._buttons) button.setAttribute("aria-expanded", value);
|
|
1202
1633
|
for (const field of this._fields || []) field.input.setAttribute("aria-expanded", value);
|
|
1203
1634
|
}
|
|
1204
1635
|
|
|
@@ -1216,12 +1647,20 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1216
1647
|
this._setExpanded(false);
|
|
1217
1648
|
this._rangeCommitId++;
|
|
1218
1649
|
this._pendingIntents.clear();
|
|
1650
|
+
this._suppressGridClick = false;
|
|
1651
|
+
if (this._drag) {
|
|
1652
|
+
this._releaseDragCapture(this._drag.pointerId);
|
|
1653
|
+
this._drag = null;
|
|
1654
|
+
}
|
|
1655
|
+
this._syncHighlight();
|
|
1219
1656
|
if (restoreFocus) {
|
|
1220
1657
|
if (this._rangeMode()) {
|
|
1658
|
+
// Range owns a workflow: after a first pick the active bound moved, so
|
|
1659
|
+
// focus follows the bound rather than the original trigger.
|
|
1221
1660
|
const endpoint = this._restoreFocusEndpoint();
|
|
1222
1661
|
if (endpoint) this._focusField(endpoint);
|
|
1223
1662
|
} else {
|
|
1224
|
-
this.
|
|
1663
|
+
this._restoreFocus();
|
|
1225
1664
|
}
|
|
1226
1665
|
}
|
|
1227
1666
|
this.dispatchEvent(new Event("close", { bubbles: true }));
|