@lekoala/date-picker 0.1.1 → 0.3.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.
@@ -5,11 +5,12 @@ import { DateFieldController } from "./date-field.js";
5
5
  import { DateRangeController, normalizeRange } from "./date-range.js";
6
6
  import { createDateAdapter, formatLongDate, resolveLocale } from "./intl.js";
7
7
  import { getDefaultMessages } from "./messages.js";
8
+ import { compareTimes, isTime } from "./time.js";
8
9
 
9
10
  let uid = 0;
10
11
 
11
12
  export class DatePickerElement extends HTMLElement {
12
- static observedAttributes = ["value", "locale", "min", "max", "open-on-focus"];
13
+ static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
13
14
 
14
15
  constructor() {
15
16
  super();
@@ -25,13 +26,21 @@ export class DatePickerElement extends HTMLElement {
25
26
  /** @type {DateRangeController | null} */
26
27
  this._range = null;
27
28
  this._orderTags = new Set();
29
+ /** Native time companions, [start, end]. @type {[HTMLInputElement | null, HTMLInputElement | null]} */
30
+ this._timeFields = [null, null];
31
+ /** Time inputs currently holding our order error. @type {Set<HTMLInputElement>} */
32
+ this._timeOrderOwned = new Set();
28
33
  /** @type {"" | "start" | "end"} */
29
34
  this._lastFocusEndpoint = "";
30
35
  this._rangeCommitId = 0;
31
36
  /** Map of clicked/keyboard-activated grid dates to the generation at click
32
37
  * time (range mode supersession/stale protection). @type {Map<string, number>} */
33
38
  this._pendingIntents = new Map();
34
- this._button = null;
39
+ /** Overlaid calendar triggers, one per field (single) or per bound (range). @type {{button: HTMLButtonElement, endpoint: "" | "start" | "end"}[]} */
40
+ this._buttons = [];
41
+ /** Control that opened the popover, so single mode can restore focus to it.
42
+ * @type {HTMLElement | null} */
43
+ this._returnFocus = null;
35
44
  this._panel = null;
36
45
  this._calendar = null;
37
46
  this._originalDescribedBy = "";
@@ -51,6 +60,8 @@ export class DatePickerElement extends HTMLElement {
51
60
  this._renderDay = null;
52
61
  /** @type {any} */
53
62
  this._isDateDisabled = null;
63
+ /** Public picker coordinate-space request. @type {"viewport" | "document"} */
64
+ this._coordinateSpace = "viewport";
54
65
  }
55
66
 
56
67
  _rangeMode() {
@@ -67,13 +78,16 @@ export class DatePickerElement extends HTMLElement {
67
78
  }
68
79
 
69
80
  _connectSingle() {
70
- const input = this.querySelector(":scope > input:not([type=hidden])");
81
+ const input = this.querySelector(
82
+ ":scope > input:not([type=hidden]):not([type=time]):not([data-time-start]):not([data-time-end])",
83
+ );
71
84
  if (!(input instanceof HTMLInputElement)) {
72
85
  console.warn("<date-picker> expects a direct child text input");
73
86
  return;
74
87
  }
75
88
  this._connected = true;
76
89
  this._input = input;
90
+ this._discoverTimeFields();
77
91
  this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
78
92
  this._field.onAttributesChanged = () => this._syncInputState();
79
93
  this._field.confirm = (date) => this._confirmDate(date);
@@ -102,7 +116,37 @@ export class DatePickerElement extends HTMLElement {
102
116
  }
103
117
  }
104
118
 
119
+ /**
120
+ * Find optional native time companions. They stay fully native (no hidden
121
+ * input, no field controller); the picker only coordinates from/to order.
122
+ * A bad composition never breaks the date picker: warn and leave times alone.
123
+ */
124
+ _discoverTimeFields() {
125
+ this._timeFields = [null, null];
126
+ /** @type {["data-time-start", "data-time-end"]} */
127
+ const markers = ["data-time-start", "data-time-end"];
128
+ for (const [index, marker] of markers.entries()) {
129
+ const matches = this.querySelectorAll(`:scope > input[${marker}]`);
130
+ if (matches.length > 1) {
131
+ console.warn(`<date-picker> expects at most one direct child input[${marker}]; ignoring extras`);
132
+ }
133
+ const candidate = matches[0];
134
+ if (candidate == null) continue;
135
+ if (!(candidate instanceof HTMLInputElement) || candidate.type !== "time") {
136
+ console.warn(`<date-picker> input[${marker}] must be an <input type="time">; ignoring it`);
137
+ continue;
138
+ }
139
+ this._timeFields[index] = candidate;
140
+ }
141
+ if (this.querySelector(":scope > input[type=time]:not([data-time-start]):not([data-time-end])")) {
142
+ console.warn("<date-picker> time inputs need [data-time-start] or [data-time-end]; ignoring it");
143
+ }
144
+ }
145
+
105
146
  _connectRange() {
147
+ if (this.querySelector(":scope > input[data-time-start], :scope > input[data-time-end]")) {
148
+ console.warn("<date-picker range> time companions are not supported yet; leaving them native");
149
+ }
106
150
  const startInput = this.querySelector(":scope > input[data-range-start]");
107
151
  const endInput = this.querySelector(":scope > input[data-range-end]");
108
152
  if (!(startInput instanceof HTMLInputElement) || !(endInput instanceof HTMLInputElement)) {
@@ -127,7 +171,7 @@ export class DatePickerElement extends HTMLElement {
127
171
  field.confirm = (date) => this._confirmDate(date);
128
172
  field.setupFormValue();
129
173
  }
130
- this._build(endInput);
174
+ this._build();
131
175
  this._setRangeAria();
132
176
  this._syncRangeFields();
133
177
  this._bindRange();
@@ -156,6 +200,8 @@ export class DatePickerElement extends HTMLElement {
156
200
  this.hide(false);
157
201
  this._controller?.abort();
158
202
  this._controller = null;
203
+ this._clearTimeOrderValidity();
204
+ this._timeFields = [null, null];
159
205
  if (this._rangeMode()) {
160
206
  for (const field of this._fields || []) this._teardownField(field);
161
207
  this._fields = null;
@@ -164,10 +210,11 @@ export class DatePickerElement extends HTMLElement {
164
210
  this._teardownField(this._field);
165
211
  }
166
212
  this._field = null;
167
- this._button?.remove();
213
+ for (const { button } of this._buttons) button.remove();
214
+ this._buttons = [];
215
+ this._returnFocus = null;
168
216
  this._panel?.remove();
169
217
  this._formatHint?.remove();
170
- this._button = null;
171
218
  this._panel = null;
172
219
  this._calendar = null;
173
220
  this._formatHint = null;
@@ -248,6 +295,16 @@ export class DatePickerElement extends HTMLElement {
248
295
  else this.removeAttribute("locale");
249
296
  }
250
297
 
298
+ /** @public Visible month-name style forwarded to the popup calendar (`long` | `short`); anything else falls back to `long`. */
299
+ get monthFormat() {
300
+ return this.getAttribute("month-format") === "short" ? "short" : "long";
301
+ }
302
+
303
+ set monthFormat(value) {
304
+ if (value === "short") this.setAttribute("month-format", "short");
305
+ else this.removeAttribute("month-format");
306
+ }
307
+
251
308
  /** @public */
252
309
  get min() {
253
310
  const value = this.getAttribute("min") || "";
@@ -342,6 +399,15 @@ export class DatePickerElement extends HTMLElement {
342
399
  if (this._connected) void this.validate();
343
400
  }
344
401
 
402
+ /** @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"} */
403
+ get coordinateSpace() {
404
+ return this._coordinateSpace;
405
+ }
406
+
407
+ set coordinateSpace(value) {
408
+ this._coordinateSpace = value === "document" ? "document" : "viewport";
409
+ }
410
+
345
411
  _adapter() {
346
412
  return this._field?.adapter ?? this._fields?.[0]?.adapter ?? createDateAdapter(this.locale);
347
413
  }
@@ -356,16 +422,22 @@ export class DatePickerElement extends HTMLElement {
356
422
  // The hidden canonical field owns submission; dynamic name/form/disabled
357
423
  // movement lives in the field controller.
358
424
  this._field?.syncInputState();
359
- if (this._button) this._button.disabled = input.disabled || input.readOnly;
425
+ const button = this._buttons[0]?.button;
426
+ if (button) button.disabled = input.disabled || input.readOnly;
360
427
  if (this._open && (input.disabled || input.readOnly)) this.hide(false);
361
428
  }
362
429
 
363
430
  _syncRangeFields() {
364
431
  if (!this._fields) return;
365
432
  for (const field of this._fields) field.syncInputState();
433
+ // Each trigger follows its own bound: a disabled end never disables the
434
+ // start trigger.
435
+ for (const { button, endpoint } of this._buttons) {
436
+ const index = endpoint === "end" ? 1 : 0;
437
+ const field = this._fields[index];
438
+ button.disabled = Boolean(field?.input.disabled || field?.input.readOnly);
439
+ }
366
440
  const active = this._range?.activeEndpoint === "end" ? 1 : 0;
367
- const disabled = this._fields.every((field) => field.input.disabled || field.input.readOnly);
368
- if (this._button) this._button.disabled = disabled;
369
441
  if (this._open && (this._fields[active]?.input.disabled || this._fields[active]?.input.readOnly)) {
370
442
  this.hide(false);
371
443
  }
@@ -386,20 +458,52 @@ export class DatePickerElement extends HTMLElement {
386
458
  }
387
459
  }
388
460
 
389
- /** @param {HTMLInputElement} [anchorInput] */
390
- _build(anchorInput) {
391
- const input = anchorInput || this._input;
392
- if (!input) return;
393
- const panelId = `${this._id}-panel`;
394
- const hintId = `${this._id}-format`;
461
+ /**
462
+ * Create one calendar trigger. It is a real button overlaid inside the
463
+ * field's own box (see the CSS): the field reserves the room, the trigger
464
+ * stays transparent, and the field's focus ring wraps both.
465
+ * @param {"" | "start" | "end"} endpoint
466
+ * @returns {HTMLButtonElement}
467
+ */
468
+ _createTrigger(endpoint) {
395
469
  const button = document.createElement("button");
396
470
  button.type = "button";
397
471
  button.className = "dp-picker-button";
398
472
  button.setAttribute("aria-haspopup", "dialog");
399
473
  button.setAttribute("aria-expanded", "false");
400
- button.setAttribute("aria-controls", panelId);
474
+ button.setAttribute("aria-controls", `${this._id}-panel`);
475
+ if (endpoint) button.dataset.endpoint = endpoint;
401
476
  button.innerHTML =
402
477
  '<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>';
478
+ return button;
479
+ }
480
+
481
+ /**
482
+ * Build the popup once and one trigger per field. Each trigger is inserted
483
+ * immediately after its own field so DOM order follows visual/tab order
484
+ * (`date -> its trigger -> next control`). The hidden ISO field may sit
485
+ * between the two; it is not focusable.
486
+ */
487
+ _build() {
488
+ const ranges = this._rangeMode();
489
+ /** @type {[HTMLInputElement, "" | "start" | "end"][]} */
490
+ const targets = [];
491
+ if (ranges) {
492
+ for (const [index, field] of (this._fields || []).entries()) {
493
+ targets.push([field.input, index === 1 ? "end" : "start"]);
494
+ }
495
+ } else if (this._input) {
496
+ targets.push([this._input, ""]);
497
+ }
498
+ if (!targets.length) return;
499
+ const panelId = `${this._id}-panel`;
500
+ const hintId = `${this._id}-format`;
501
+
502
+ this._buttons = targets.map(([input, endpoint]) => {
503
+ const button = this._createTrigger(endpoint);
504
+ input.insertAdjacentElement("afterend", button);
505
+ return { button, endpoint };
506
+ });
403
507
 
404
508
  const panel = document.createElement("div");
405
509
  panel.id = panelId;
@@ -417,23 +521,21 @@ export class DatePickerElement extends HTMLElement {
417
521
  hint.id = hintId;
418
522
  hint.className = "dp-visually-hidden";
419
523
 
420
- input.insertAdjacentElement("afterend", button);
421
- button.insertAdjacentElement("afterend", panel);
422
- panel.insertAdjacentElement("afterend", hint);
524
+ // Out of flow (popover / visually hidden): placement is irrelevant.
525
+ this.append(panel, hint);
423
526
 
424
- this._button = button;
425
527
  this._panel = panel;
426
528
  this._calendar = calendar;
427
529
  this._formatHint = hint;
428
530
 
429
- if (!this._rangeMode()) {
430
- this._originalDescribedBy = input.getAttribute("aria-describedby") || "";
531
+ if (!ranges && this._input) {
532
+ this._originalDescribedBy = this._input.getAttribute("aria-describedby") || "";
431
533
  const describedBy = [this._originalDescribedBy, hintId].filter(Boolean).join(" ");
432
- input.setAttribute("aria-describedby", describedBy);
433
- input.setAttribute("role", "combobox");
434
- input.setAttribute("aria-haspopup", "dialog");
435
- input.setAttribute("aria-expanded", "false");
436
- input.setAttribute("aria-controls", panelId);
534
+ this._input.setAttribute("aria-describedby", describedBy);
535
+ this._input.setAttribute("role", "combobox");
536
+ this._input.setAttribute("aria-haspopup", "dialog");
537
+ this._input.setAttribute("aria-expanded", "false");
538
+ this._input.setAttribute("aria-controls", panelId);
437
539
  }
438
540
  this._refreshLocale();
439
541
  this._refreshButtonLabel();
@@ -441,7 +543,7 @@ export class DatePickerElement extends HTMLElement {
441
543
 
442
544
  _bind() {
443
545
  const input = this._input;
444
- const button = this._button;
546
+ const button = this._buttons[0]?.button;
445
547
  const calendar = this._calendar;
446
548
  if (!input || !button || !calendar) return;
447
549
  const controller = new AbortController();
@@ -479,6 +581,12 @@ export class DatePickerElement extends HTMLElement {
479
581
  },
480
582
  { signal },
481
583
  );
584
+ for (const [index, timeInput] of this._timeFields.entries()) {
585
+ if (!timeInput) continue;
586
+ const endpoint = index === 1 ? "end" : "start";
587
+ timeInput.addEventListener("input", () => this._revalidateTimeOrder(endpoint), { signal });
588
+ timeInput.addEventListener("change", () => this._revalidateTimeOrder(endpoint), { signal });
589
+ }
482
590
  button.addEventListener(
483
591
  "click",
484
592
  (event) => {
@@ -494,10 +602,11 @@ export class DatePickerElement extends HTMLElement {
494
602
  this.hide(false);
495
603
  return;
496
604
  }
497
- this.show();
605
+ this.show({ returnFocus: button });
498
606
  },
499
607
  { signal },
500
608
  );
609
+ button.addEventListener("keydown", (event) => this._onTriggerKeyDown("", event), { signal });
501
610
  calendar.addEventListener(
502
611
  "dateactivate",
503
612
  (event) => {
@@ -512,7 +621,7 @@ export class DatePickerElement extends HTMLElement {
512
621
  this._field?.dirty();
513
622
  this._setValue(date, { emit: true, format: true });
514
623
  this.hide(false);
515
- this._focusInput();
624
+ this._restoreFocus();
516
625
  });
517
626
  },
518
627
  { signal },
@@ -545,9 +654,8 @@ export class DatePickerElement extends HTMLElement {
545
654
  }
546
655
 
547
656
  _bindRange() {
548
- const button = this._button;
549
657
  const calendar = this._calendar;
550
- if (!this._fields || !button || !calendar) return;
658
+ if (!this._fields || !this._buttons.length || !calendar) return;
551
659
  const controller = new AbortController();
552
660
  this._controller = controller;
553
661
  const { signal } = controller;
@@ -564,21 +672,16 @@ export class DatePickerElement extends HTMLElement {
564
672
  );
565
673
  field.input.addEventListener("keydown", (event) => this._onFieldKeyDown(endpoint, event), { signal });
566
674
  }
567
- button.addEventListener(
568
- "click",
569
- (event) => {
570
- if (this._open) {
571
- if (event.detail === 0) {
572
- setTimeout(() => this._calendar?.focusGrid(), 0);
573
- return;
574
- }
575
- this.hide(false);
576
- return;
577
- }
578
- this.show();
579
- },
580
- { signal },
581
- );
675
+ for (const entry of this._buttons) {
676
+ const endpoint = entry.endpoint;
677
+ if (endpoint === "") continue;
678
+ entry.button.addEventListener("click", (event) => this._onRangeTriggerClick(endpoint, event), {
679
+ signal,
680
+ });
681
+ entry.button.addEventListener("keydown", (event) => this._onTriggerKeyDown(endpoint, event), {
682
+ signal,
683
+ });
684
+ }
582
685
  // Record grid-activation intent synchronously (click or Enter/Space). The
583
686
  // picker only commits a dateactivate whose generation still matches, so a
584
687
  // response that resolves after the active endpoint changed (or after a
@@ -649,9 +752,72 @@ export class DatePickerElement extends HTMLElement {
649
752
  this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
650
753
  }
651
754
 
755
+ /**
756
+ * Point the shared calendar at one range bound and invalidate any pending
757
+ * activation, so a late response can never commit on the wrong bound.
758
+ * @param {"start" | "end"} endpoint
759
+ */
760
+ _activateRangeEndpoint(endpoint) {
761
+ const calendar = this._calendar;
762
+ if (!this._range || !calendar) return;
763
+ this._range.focus(endpoint);
764
+ this._rangeCommitId++;
765
+ this._pendingIntents.clear();
766
+ this._lastFocusEndpoint = endpoint;
767
+ const target = this._boundCanonical(endpoint) || todayISO();
768
+ calendar.display = monthKey(target);
769
+ calendar.focusedDate = target;
770
+ }
771
+
772
+ /**
773
+ * Range has one trigger per bound. A press on the other bound's trigger is a
774
+ * local switch, not a toggle: it repoints the single open popup instead of
775
+ * closing it. Only a press on the already-active bound toggles shut.
776
+ * @param {"start" | "end"} endpoint
777
+ * @param {MouseEvent} event
778
+ */
779
+ _onRangeTriggerClick(endpoint, event) {
780
+ if (!this._open) {
781
+ this.show({ endpoint });
782
+ return;
783
+ }
784
+ if (event.detail === 0) {
785
+ // Keyboard activation of an already-open picker: switch the bound, then
786
+ // move into the grid. Escape keeps closing it.
787
+ this._activateRangeEndpoint(endpoint);
788
+ setTimeout(() => this._calendar?.focusGrid(), 0);
789
+ return;
790
+ }
791
+ if (endpoint !== this._range?.activeEndpoint) {
792
+ this._activateRangeEndpoint(endpoint);
793
+ return;
794
+ }
795
+ this.hide(false);
796
+ }
797
+
798
+ /**
799
+ * ArrowDown on a trigger opens the popup and drops into the grid, like the
800
+ * field itself (Enter/Space go through the click path). Needed because focus
801
+ * now returns to the trigger that opened the popover.
802
+ * @param {"" | "start" | "end"} endpoint
803
+ * @param {KeyboardEvent} event
804
+ */
805
+ _onTriggerKeyDown(endpoint, event) {
806
+ if (event.key !== "ArrowDown") return;
807
+ event.preventDefault();
808
+ if (this._open) {
809
+ if (this._rangeMode() && endpoint) this._activateRangeEndpoint(endpoint);
810
+ // Chromium drops script-initiated focus changes during keydown dispatch.
811
+ setTimeout(() => this._calendar?.focusGrid(), 0);
812
+ return;
813
+ }
814
+ if (this._rangeMode()) this.show({ endpoint: endpoint || undefined });
815
+ else this.show({ returnFocus: this._buttons[0]?.button });
816
+ }
817
+
652
818
  /** @param {"" | "start" | "end"} endpoint @param {KeyboardEvent} event */
653
819
  _onFieldKeyDown(endpoint, event) {
654
- if (event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown")) {
820
+ if (event.key === "ArrowDown") {
655
821
  event.preventDefault();
656
822
  if (this._rangeMode() && endpoint) {
657
823
  this._lastFocusEndpoint = endpoint;
@@ -702,6 +868,8 @@ export class DatePickerElement extends HTMLElement {
702
868
  const cell = event.target.closest(".dp-day[data-date]");
703
869
  const date = cell?.getAttribute("data-date") || "";
704
870
  if (!isDate(date)) return;
871
+ // Each activation supersedes every earlier pending one.
872
+ this._pendingIntents.clear();
705
873
  this._pendingIntents.set(date, ++this._rangeCommitId);
706
874
  }
707
875
 
@@ -721,6 +889,7 @@ export class DatePickerElement extends HTMLElement {
721
889
  calendar.locale = this.locale;
722
890
  calendar.min = this.min;
723
891
  calendar.max = this.max;
892
+ calendar.monthFormat = this.monthFormat;
724
893
  calendar.messages = this._messages;
725
894
  calendar.source = this._source;
726
895
  calendar.dateState = this._dateState;
@@ -761,25 +930,29 @@ export class DatePickerElement extends HTMLElement {
761
930
  }
762
931
 
763
932
  _refreshButtonLabel() {
764
- if (!this._button) return;
933
+ const single = this._buttons[0]?.button;
934
+ if (!single) return;
765
935
  if (this._rangeMode()) {
766
936
  this._refreshRangeButtonLabel();
767
937
  return;
768
938
  }
769
939
  const prefix = this._value ? this._messages.changeDate : this._messages.chooseDate;
770
940
  const suffix = this._value ? `, ${formatLongDate(this._value, this.locale)}` : "";
771
- this._button.setAttribute("aria-label", `${prefix}${suffix}`);
941
+ single.setAttribute("aria-label", `${prefix}${suffix}`);
772
942
  }
773
943
 
944
+ /** Each bound has its own trigger, so each needs its own accessible name: the
945
+ * bound label is what tells two otherwise identical triggers apart. */
774
946
  _refreshRangeButtonLabel() {
775
- if (!this._button || !this._range) return;
947
+ if (!this._range) return;
776
948
  const { start, end } = this._range;
777
- const hasRange = Boolean(start && end);
778
- const prefix = hasRange ? this._messages.changeDate : this._messages.chooseDate;
779
- const suffix = hasRange
780
- ? `, ${formatLongDate(start, this.locale)} – ${formatLongDate(end, this.locale)}`
781
- : "";
782
- this._button.setAttribute("aria-label", `${prefix}${suffix}`);
949
+ for (const { button, endpoint } of this._buttons) {
950
+ const bound = endpoint === "end" ? end : start;
951
+ const prefix = bound ? this._messages.changeDate : this._messages.chooseDate;
952
+ const suffix = bound ? `, ${formatLongDate(bound, this.locale)}` : "";
953
+ const boundLabel = endpoint === "end" ? this._messages.rangeEnd : this._messages.rangeStart;
954
+ button.setAttribute("aria-label", `${prefix}${suffix}, ${boundLabel}`);
955
+ }
783
956
  }
784
957
 
785
958
  /** @param {string} value */
@@ -855,7 +1028,9 @@ export class DatePickerElement extends HTMLElement {
855
1028
  this._orderTags.delete(index);
856
1029
  return false;
857
1030
  }
858
- this._setBound(which, result.value || "", { emit: true, user: true, format: result.status === "ok" });
1031
+ // Typed commits keep native input/change only; synthetic events are
1032
+ // reserved for calendar-driven changes (no native input event occurred).
1033
+ this._setBound(which, result.value || "", { emit: false, user: true, format: result.status === "ok" });
859
1034
  return true;
860
1035
  }
861
1036
 
@@ -959,6 +1134,47 @@ export class DatePickerElement extends HTMLElement {
959
1134
  }
960
1135
  }
961
1136
 
1137
+ /**
1138
+ * Single-mode from/to order on the shared date: `start <= end`.
1139
+ * The order error is attributed to the bound that was just modified; the
1140
+ * other bound only loses a stale order error, never its native validity.
1141
+ * Missing, empty or disabled times fall back to no order constraint: times
1142
+ * are never implicitly required. Equality stays valid (duration rules are
1143
+ * application-owned).
1144
+ * @param {"start" | "end"} which
1145
+ */
1146
+ _revalidateTimeOrder(which) {
1147
+ const [startInput, endInput] = this._timeFields;
1148
+ /** @type {["start", "end"]} */
1149
+ const bounds = ["start", "end"];
1150
+ const inputs = [startInput, endInput];
1151
+ const startValue = startInput && !startInput.disabled ? startInput.value.trim() : "";
1152
+ const endValue = endInput && !endInput.disabled ? endInput.value.trim() : "";
1153
+ const inverted =
1154
+ Boolean(startInput && endInput && startValue && endValue) &&
1155
+ isTime(startValue) &&
1156
+ isTime(endValue) &&
1157
+ compareTimes(endValue, startValue) < 0;
1158
+ for (const [index, bound] of bounds.entries()) {
1159
+ const input = inputs[index];
1160
+ if (!input) continue;
1161
+ const message = bound === "end" ? this._messages.rangeOrderEnd : this._messages.rangeOrderStart;
1162
+ const shouldHaveOrder = inverted && bound === which;
1163
+ if (shouldHaveOrder) {
1164
+ input.setCustomValidity(message);
1165
+ this._timeOrderOwned.add(input);
1166
+ } else if (this._timeOrderOwned.has(input)) {
1167
+ input.setCustomValidity("");
1168
+ this._timeOrderOwned.delete(input);
1169
+ }
1170
+ }
1171
+ }
1172
+
1173
+ _clearTimeOrderValidity() {
1174
+ for (const input of this._timeOrderOwned) input.setCustomValidity("");
1175
+ this._timeOrderOwned.clear();
1176
+ }
1177
+
962
1178
  _restoreDefault() {
963
1179
  const field = this._field;
964
1180
  if (!field) return;
@@ -1001,13 +1217,19 @@ export class DatePickerElement extends HTMLElement {
1001
1217
  }, 0);
1002
1218
  }
1003
1219
 
1004
- _focusInput() {
1005
- // focus() must not run synchronously from a keydown handler (Chromium drops
1006
- // focus changes there) and must not re-open the popover under the default
1007
- // open-on-focus. Deferring keeps _suppressFocusOpen active for the call.
1220
+ /**
1221
+ * Restore focus to the control that opened the popover (single mode). focus()
1222
+ * must not run synchronously from a keydown handler (Chromium drops focus
1223
+ * changes there) and must not re-open the popover under the default
1224
+ * open-on-focus. Deferring keeps _suppressFocusOpen active for the call.
1225
+ */
1226
+ _restoreFocus() {
1227
+ const target = this._returnFocus;
1228
+ this._returnFocus = null;
1229
+ if (!(target instanceof HTMLElement) || !target.isConnected || target.matches(":disabled")) return;
1008
1230
  this._suppressFocusOpen = true;
1009
1231
  setTimeout(() => {
1010
- this._input?.focus();
1232
+ target.focus();
1011
1233
  this._suppressFocusOpen = false;
1012
1234
  }, 0);
1013
1235
  }
@@ -1051,40 +1273,50 @@ export class DatePickerElement extends HTMLElement {
1051
1273
  if (!input) return true;
1052
1274
  if (!input.value.trim()) {
1053
1275
  input.setCustomValidity("");
1054
- return input.checkValidity();
1276
+ } else {
1277
+ await this._commitText(false, { force: true });
1055
1278
  }
1056
- await this._commitText(false, { force: true });
1057
- return input.checkValidity();
1279
+ // Same start-then-end order as the range path, so a from/to inversion
1280
+ // ends reported on "end". A programmatic time change resyncs here too.
1281
+ this._revalidateTimeOrder("start");
1282
+ this._revalidateTimeOrder("end");
1283
+ const timesValid = this._timeFields.every((timeInput) => timeInput?.checkValidity() ?? true);
1284
+ return input.checkValidity() && timesValid;
1058
1285
  }
1059
1286
 
1060
- /** @public @param {{moveFocus?:boolean}} [options] */
1287
+ /** @public @param {{moveFocus?:boolean, endpoint?:"start"|"end", returnFocus?:HTMLElement}} [options] */
1061
1288
  show(options = {}) {
1062
1289
  const panel = this._panel;
1063
1290
  const calendar = this._calendar;
1064
- const button = this._button;
1065
- if (!panel || !calendar || !button || this._open) return;
1291
+ if (!panel || !calendar || !this._buttons.length || this._open) return;
1066
1292
  if (this._rangeMode()) {
1067
- const endpoint = this._resolveRangeEndpoint();
1293
+ const endpoint = options.endpoint || this._resolveRangeEndpoint();
1068
1294
  if (!endpoint) return;
1069
- this._range?.focus(endpoint);
1070
- this._rangeCommitId++;
1071
- this._pendingIntents.clear();
1072
- const target = this._boundCanonical(endpoint) || todayISO();
1073
- calendar.display = monthKey(target);
1074
- calendar.focusedDate = target;
1075
- this._lastFocusEndpoint = endpoint;
1295
+ this._activateRangeEndpoint(endpoint);
1076
1296
  } else {
1077
1297
  const input = this._input;
1078
1298
  if (!input || input.disabled || input.readOnly) return;
1079
1299
  const target = this._value || this._adapter().parse(input.value) || todayISO();
1080
1300
  calendar.display = monthKey(target);
1081
1301
  calendar.focusedDate = target;
1302
+ // Popover convention: closing returns focus to the control that opened.
1303
+ this._returnFocus = options.returnFocus instanceof HTMLElement ? options.returnFocus : input;
1082
1304
  }
1083
1305
  panel.showPopover();
1084
1306
  this._open = true;
1085
1307
  this._setExpanded(true);
1308
+ // Freeze the explicitly configured coordinate space once per opening: a
1309
+ // change made while open takes effect on the next opening, never the
1310
+ // current one.
1311
+ const coordinateSpace = this.coordinateSpace;
1312
+ panel.style.position = coordinateSpace === "document" ? "absolute" : "fixed";
1086
1313
  const position = () =>
1087
- reposition(this, panel, { placement: "bottom-start", distance: 4, shiftPadding: 8 });
1314
+ reposition(this, panel, {
1315
+ placement: "bottom-start",
1316
+ distance: 4,
1317
+ shiftPadding: 8,
1318
+ coordinateSpace,
1319
+ });
1088
1320
  position();
1089
1321
  this._stopTracking = autoUpdate(this, panel, position);
1090
1322
  if (options.moveFocus !== false) queueMicrotask(() => calendar.focusGrid());
@@ -1095,7 +1327,7 @@ export class DatePickerElement extends HTMLElement {
1095
1327
  _setExpanded(expanded) {
1096
1328
  const value = expanded ? "true" : "false";
1097
1329
  this._input?.setAttribute("aria-expanded", value);
1098
- this._button?.setAttribute("aria-expanded", value);
1330
+ for (const { button } of this._buttons) button.setAttribute("aria-expanded", value);
1099
1331
  for (const field of this._fields || []) field.input.setAttribute("aria-expanded", value);
1100
1332
  }
1101
1333
 
@@ -1115,10 +1347,12 @@ export class DatePickerElement extends HTMLElement {
1115
1347
  this._pendingIntents.clear();
1116
1348
  if (restoreFocus) {
1117
1349
  if (this._rangeMode()) {
1350
+ // Range owns a workflow: after a first pick the active bound moved, so
1351
+ // focus follows the bound rather than the original trigger.
1118
1352
  const endpoint = this._restoreFocusEndpoint();
1119
1353
  if (endpoint) this._focusField(endpoint);
1120
1354
  } else {
1121
- this._focusInput();
1355
+ this._restoreFocus();
1122
1356
  }
1123
1357
  }
1124
1358
  this.dispatchEvent(new Event("close", { bubbles: true }));
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  formatLongDate,
9
9
  formatMonthYear,
10
10
  monthNames,
11
+ monthYearOrder,
11
12
  resolveLocale,
12
13
  weekdayNames,
13
14
  } from "./intl.js";