@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.
@@ -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
- /** Map of clicked/keyboard-activated grid dates to the generation at click
37
- * time (range mode supersession/stale protection). @type {Map<string, number>} */
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
- this._button = null;
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(endInput);
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._button?.remove();
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
- if (this._button) this._button.disabled = input.disabled || input.readOnly;
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
- /** @param {HTMLInputElement} [anchorInput] */
440
- _build(anchorInput) {
441
- const input = anchorInput || this._input;
442
- if (!input) return;
443
- const panelId = `${this._id}-panel`;
444
- const hintId = `${this._id}-format`;
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", panelId);
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
- input.insertAdjacentElement("afterend", button);
471
- button.insertAdjacentElement("afterend", panel);
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._rangeMode()) {
480
- this._originalDescribedBy = input.getAttribute("aria-describedby") || "";
543
+ if (!ranges && this._input) {
544
+ this._originalDescribedBy = this._input.getAttribute("aria-describedby") || "";
481
545
  const describedBy = [this._originalDescribedBy, hintId].filter(Boolean).join(" ");
482
- input.setAttribute("aria-describedby", describedBy);
483
- input.setAttribute("role", "combobox");
484
- input.setAttribute("aria-haspopup", "dialog");
485
- input.setAttribute("aria-expanded", "false");
486
- input.setAttribute("aria-controls", panelId);
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._button;
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._focusInput();
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 || !button || !calendar) return;
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
- button.addEventListener(
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._open) {
627
- if (event.detail === 0) {
628
- setTimeout(() => this._calendar?.focusGrid(), 0);
629
- return;
630
- }
631
- this.hide(false);
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.show();
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 pending = this._pendingIntents.get(date);
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 (pending === undefined || pending !== this._rangeCommitId) return;
733
+ if (intent === undefined || intent.id !== this._rangeCommitId) return;
659
734
  this._pendingIntents.delete(date);
660
- const result = this._range.activate(date, (which) => {
661
- const index = which === "end" ? 1 : 0;
662
- const field = this._fields?.[index];
663
- return field != null && !field.input.disabled && !field.input.readOnly;
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._setBound(result.endpoint, date, { emit: true, user: true });
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" || (event.altKey && 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
- this._pendingIntents.set(date, ++this._rangeCommitId);
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
- if (!this._button) return;
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
- this._button.setAttribute("aria-label", `${prefix}${suffix}`);
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._button || !this._range) return;
1234
+ if (!this._range) return;
833
1235
  const { start, end } = this._range;
834
- const hasRange = Boolean(start && end);
835
- const prefix = hasRange ? this._messages.changeDate : this._messages.chooseDate;
836
- const suffix = hasRange
837
- ? `, ${formatLongDate(start, this.locale)} – ${formatLongDate(end, this.locale)}`
838
- : "";
839
- this._button.setAttribute("aria-label", `${prefix}${suffix}`);
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
- if (this._value) this._calendar.focusedDate = this._value;
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 @returns {Promise<{ok: boolean, message?: string}>}
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
- const display = monthKey(date);
882
- if (calendar.display !== display) calendar.display = display;
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 || "", { emit, format: result.status === "ok" });
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
- this._setBound(which, result.value || "", { emit: true, user: true, format: result.status === "ok" });
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
- /** Forward only a displayable range to the calendar band. An inverted or
978
- * start-less business range shows no misleading band. */
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
- const { start, end } = this._range;
982
- if (!start) {
983
- this._calendar.highlightedRange = { start: "", end: "" };
984
- return;
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
- _focusInput() {
1103
- // focus() must not run synchronously from a keydown handler (Chromium drops
1104
- // focus changes there) and must not re-open the popover under the default
1105
- // open-on-focus. Deferring keeps _suppressFocusOpen active for the call.
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
- this._input?.focus();
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
- const button = this._button;
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._range?.focus(endpoint);
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, { placement: "bottom-start", distance: 4, shiftPadding: 8 });
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._button?.setAttribute("aria-expanded", value);
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._focusInput();
1663
+ this._restoreFocus();
1225
1664
  }
1226
1665
  }
1227
1666
  this.dispatchEvent(new Event("close", { bubbles: true }));