@lekoala/date-picker 0.2.0 → 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.
@@ -36,7 +36,11 @@ export class DatePickerElement extends HTMLElement {
36
36
  /** Map of clicked/keyboard-activated grid dates to the generation at click
37
37
  * time (range mode supersession/stale protection). @type {Map<string, number>} */
38
38
  this._pendingIntents = new Map();
39
- 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;
40
44
  this._panel = null;
41
45
  this._calendar = null;
42
46
  this._originalDescribedBy = "";
@@ -56,6 +60,8 @@ export class DatePickerElement extends HTMLElement {
56
60
  this._renderDay = null;
57
61
  /** @type {any} */
58
62
  this._isDateDisabled = null;
63
+ /** Public picker coordinate-space request. @type {"viewport" | "document"} */
64
+ this._coordinateSpace = "viewport";
59
65
  }
60
66
 
61
67
  _rangeMode() {
@@ -165,7 +171,7 @@ export class DatePickerElement extends HTMLElement {
165
171
  field.confirm = (date) => this._confirmDate(date);
166
172
  field.setupFormValue();
167
173
  }
168
- this._build(endInput);
174
+ this._build();
169
175
  this._setRangeAria();
170
176
  this._syncRangeFields();
171
177
  this._bindRange();
@@ -204,10 +210,11 @@ export class DatePickerElement extends HTMLElement {
204
210
  this._teardownField(this._field);
205
211
  }
206
212
  this._field = null;
207
- this._button?.remove();
213
+ for (const { button } of this._buttons) button.remove();
214
+ this._buttons = [];
215
+ this._returnFocus = null;
208
216
  this._panel?.remove();
209
217
  this._formatHint?.remove();
210
- this._button = null;
211
218
  this._panel = null;
212
219
  this._calendar = null;
213
220
  this._formatHint = null;
@@ -392,6 +399,15 @@ export class DatePickerElement extends HTMLElement {
392
399
  if (this._connected) void this.validate();
393
400
  }
394
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
+
395
411
  _adapter() {
396
412
  return this._field?.adapter ?? this._fields?.[0]?.adapter ?? createDateAdapter(this.locale);
397
413
  }
@@ -406,16 +422,22 @@ export class DatePickerElement extends HTMLElement {
406
422
  // The hidden canonical field owns submission; dynamic name/form/disabled
407
423
  // movement lives in the field controller.
408
424
  this._field?.syncInputState();
409
- 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;
410
427
  if (this._open && (input.disabled || input.readOnly)) this.hide(false);
411
428
  }
412
429
 
413
430
  _syncRangeFields() {
414
431
  if (!this._fields) return;
415
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
+ }
416
440
  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
441
  if (this._open && (this._fields[active]?.input.disabled || this._fields[active]?.input.readOnly)) {
420
442
  this.hide(false);
421
443
  }
@@ -436,20 +458,52 @@ export class DatePickerElement extends HTMLElement {
436
458
  }
437
459
  }
438
460
 
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`;
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) {
445
469
  const button = document.createElement("button");
446
470
  button.type = "button";
447
471
  button.className = "dp-picker-button";
448
472
  button.setAttribute("aria-haspopup", "dialog");
449
473
  button.setAttribute("aria-expanded", "false");
450
- button.setAttribute("aria-controls", panelId);
474
+ button.setAttribute("aria-controls", `${this._id}-panel`);
475
+ if (endpoint) button.dataset.endpoint = endpoint;
451
476
  button.innerHTML =
452
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
+ });
453
507
 
454
508
  const panel = document.createElement("div");
455
509
  panel.id = panelId;
@@ -467,23 +521,21 @@ export class DatePickerElement extends HTMLElement {
467
521
  hint.id = hintId;
468
522
  hint.className = "dp-visually-hidden";
469
523
 
470
- input.insertAdjacentElement("afterend", button);
471
- button.insertAdjacentElement("afterend", panel);
472
- panel.insertAdjacentElement("afterend", hint);
524
+ // Out of flow (popover / visually hidden): placement is irrelevant.
525
+ this.append(panel, hint);
473
526
 
474
- this._button = button;
475
527
  this._panel = panel;
476
528
  this._calendar = calendar;
477
529
  this._formatHint = hint;
478
530
 
479
- if (!this._rangeMode()) {
480
- this._originalDescribedBy = input.getAttribute("aria-describedby") || "";
531
+ if (!ranges && this._input) {
532
+ this._originalDescribedBy = this._input.getAttribute("aria-describedby") || "";
481
533
  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);
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);
487
539
  }
488
540
  this._refreshLocale();
489
541
  this._refreshButtonLabel();
@@ -491,7 +543,7 @@ export class DatePickerElement extends HTMLElement {
491
543
 
492
544
  _bind() {
493
545
  const input = this._input;
494
- const button = this._button;
546
+ const button = this._buttons[0]?.button;
495
547
  const calendar = this._calendar;
496
548
  if (!input || !button || !calendar) return;
497
549
  const controller = new AbortController();
@@ -550,10 +602,11 @@ export class DatePickerElement extends HTMLElement {
550
602
  this.hide(false);
551
603
  return;
552
604
  }
553
- this.show();
605
+ this.show({ returnFocus: button });
554
606
  },
555
607
  { signal },
556
608
  );
609
+ button.addEventListener("keydown", (event) => this._onTriggerKeyDown("", event), { signal });
557
610
  calendar.addEventListener(
558
611
  "dateactivate",
559
612
  (event) => {
@@ -568,7 +621,7 @@ export class DatePickerElement extends HTMLElement {
568
621
  this._field?.dirty();
569
622
  this._setValue(date, { emit: true, format: true });
570
623
  this.hide(false);
571
- this._focusInput();
624
+ this._restoreFocus();
572
625
  });
573
626
  },
574
627
  { signal },
@@ -601,9 +654,8 @@ export class DatePickerElement extends HTMLElement {
601
654
  }
602
655
 
603
656
  _bindRange() {
604
- const button = this._button;
605
657
  const calendar = this._calendar;
606
- if (!this._fields || !button || !calendar) return;
658
+ if (!this._fields || !this._buttons.length || !calendar) return;
607
659
  const controller = new AbortController();
608
660
  this._controller = controller;
609
661
  const { signal } = controller;
@@ -620,21 +672,16 @@ export class DatePickerElement extends HTMLElement {
620
672
  );
621
673
  field.input.addEventListener("keydown", (event) => this._onFieldKeyDown(endpoint, event), { signal });
622
674
  }
623
- button.addEventListener(
624
- "click",
625
- (event) => {
626
- if (this._open) {
627
- if (event.detail === 0) {
628
- setTimeout(() => this._calendar?.focusGrid(), 0);
629
- return;
630
- }
631
- this.hide(false);
632
- return;
633
- }
634
- this.show();
635
- },
636
- { signal },
637
- );
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
+ }
638
685
  // Record grid-activation intent synchronously (click or Enter/Space). The
639
686
  // picker only commits a dateactivate whose generation still matches, so a
640
687
  // response that resolves after the active endpoint changed (or after a
@@ -705,9 +752,72 @@ export class DatePickerElement extends HTMLElement {
705
752
  this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
706
753
  }
707
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
+
708
818
  /** @param {"" | "start" | "end"} endpoint @param {KeyboardEvent} event */
709
819
  _onFieldKeyDown(endpoint, event) {
710
- if (event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown")) {
820
+ if (event.key === "ArrowDown") {
711
821
  event.preventDefault();
712
822
  if (this._rangeMode() && endpoint) {
713
823
  this._lastFocusEndpoint = endpoint;
@@ -758,6 +868,8 @@ export class DatePickerElement extends HTMLElement {
758
868
  const cell = event.target.closest(".dp-day[data-date]");
759
869
  const date = cell?.getAttribute("data-date") || "";
760
870
  if (!isDate(date)) return;
871
+ // Each activation supersedes every earlier pending one.
872
+ this._pendingIntents.clear();
761
873
  this._pendingIntents.set(date, ++this._rangeCommitId);
762
874
  }
763
875
 
@@ -818,25 +930,29 @@ export class DatePickerElement extends HTMLElement {
818
930
  }
819
931
 
820
932
  _refreshButtonLabel() {
821
- if (!this._button) return;
933
+ const single = this._buttons[0]?.button;
934
+ if (!single) return;
822
935
  if (this._rangeMode()) {
823
936
  this._refreshRangeButtonLabel();
824
937
  return;
825
938
  }
826
939
  const prefix = this._value ? this._messages.changeDate : this._messages.chooseDate;
827
940
  const suffix = this._value ? `, ${formatLongDate(this._value, this.locale)}` : "";
828
- this._button.setAttribute("aria-label", `${prefix}${suffix}`);
941
+ single.setAttribute("aria-label", `${prefix}${suffix}`);
829
942
  }
830
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. */
831
946
  _refreshRangeButtonLabel() {
832
- if (!this._button || !this._range) return;
947
+ if (!this._range) return;
833
948
  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}`);
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
+ }
840
956
  }
841
957
 
842
958
  /** @param {string} value */
@@ -912,7 +1028,9 @@ export class DatePickerElement extends HTMLElement {
912
1028
  this._orderTags.delete(index);
913
1029
  return false;
914
1030
  }
915
- 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" });
916
1034
  return true;
917
1035
  }
918
1036
 
@@ -1099,13 +1217,19 @@ export class DatePickerElement extends HTMLElement {
1099
1217
  }, 0);
1100
1218
  }
1101
1219
 
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.
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;
1106
1230
  this._suppressFocusOpen = true;
1107
1231
  setTimeout(() => {
1108
- this._input?.focus();
1232
+ target.focus();
1109
1233
  this._suppressFocusOpen = false;
1110
1234
  }, 0);
1111
1235
  }
@@ -1160,34 +1284,39 @@ export class DatePickerElement extends HTMLElement {
1160
1284
  return input.checkValidity() && timesValid;
1161
1285
  }
1162
1286
 
1163
- /** @public @param {{moveFocus?:boolean}} [options] */
1287
+ /** @public @param {{moveFocus?:boolean, endpoint?:"start"|"end", returnFocus?:HTMLElement}} [options] */
1164
1288
  show(options = {}) {
1165
1289
  const panel = this._panel;
1166
1290
  const calendar = this._calendar;
1167
- const button = this._button;
1168
- if (!panel || !calendar || !button || this._open) return;
1291
+ if (!panel || !calendar || !this._buttons.length || this._open) return;
1169
1292
  if (this._rangeMode()) {
1170
- const endpoint = this._resolveRangeEndpoint();
1293
+ const endpoint = options.endpoint || this._resolveRangeEndpoint();
1171
1294
  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;
1295
+ this._activateRangeEndpoint(endpoint);
1179
1296
  } else {
1180
1297
  const input = this._input;
1181
1298
  if (!input || input.disabled || input.readOnly) return;
1182
1299
  const target = this._value || this._adapter().parse(input.value) || todayISO();
1183
1300
  calendar.display = monthKey(target);
1184
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;
1185
1304
  }
1186
1305
  panel.showPopover();
1187
1306
  this._open = true;
1188
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";
1189
1313
  const position = () =>
1190
- 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
+ });
1191
1320
  position();
1192
1321
  this._stopTracking = autoUpdate(this, panel, position);
1193
1322
  if (options.moveFocus !== false) queueMicrotask(() => calendar.focusGrid());
@@ -1198,7 +1327,7 @@ export class DatePickerElement extends HTMLElement {
1198
1327
  _setExpanded(expanded) {
1199
1328
  const value = expanded ? "true" : "false";
1200
1329
  this._input?.setAttribute("aria-expanded", value);
1201
- this._button?.setAttribute("aria-expanded", value);
1330
+ for (const { button } of this._buttons) button.setAttribute("aria-expanded", value);
1202
1331
  for (const field of this._fields || []) field.input.setAttribute("aria-expanded", value);
1203
1332
  }
1204
1333
 
@@ -1218,10 +1347,12 @@ export class DatePickerElement extends HTMLElement {
1218
1347
  this._pendingIntents.clear();
1219
1348
  if (restoreFocus) {
1220
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.
1221
1352
  const endpoint = this._restoreFocusEndpoint();
1222
1353
  if (endpoint) this._focusField(endpoint);
1223
1354
  } else {
1224
- this._focusInput();
1355
+ this._restoreFocus();
1225
1356
  }
1226
1357
  }
1227
1358
  this.dispatchEvent(new Event("close", { bubbles: true }));