@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.
@@ -2,12 +2,13 @@ import { CalendarModel } from "./calendar-model.js";
2
2
  import {
3
3
  addDays,
4
4
  clampDate,
5
+ clampDisplayMonth,
5
6
  compareDates,
7
+ endOfMonth,
6
8
  getMonthWeeks,
7
9
  isDate,
8
10
  isoWeekNumber,
9
11
  monthKey,
10
- shiftMonth,
11
12
  todayISO,
12
13
  } from "./date.js";
13
14
  import { normalizeRange, rangePosition } from "./date-range.js";
@@ -46,20 +47,43 @@ function yearOf(month) {
46
47
  return Number(month.slice(0, 4));
47
48
  }
48
49
 
50
+ /**
51
+ * Step a `YYYY-MM` key by ±1, or `null` when the step leaves the canonical
52
+ * 0001-01..9999-12 window. Pure arithmetic, so an out-of-range intermediate
53
+ * date is never constructed and the calendar edges cannot throw.
54
+ * @param {string} month
55
+ * @param {number} amount
56
+ * @returns {string | null}
57
+ */
58
+ function stepMonthKey(month, amount) {
59
+ const [year, index] = month.split("-").map(Number);
60
+ const absolute = year * 12 + (index - 1) + amount;
61
+ const stepYear = Math.floor(absolute / 12);
62
+ const stepMonth = (((absolute % 12) + 12) % 12) + 1;
63
+ if (stepYear < 1 || stepYear > 9999) return null;
64
+ return `${String(stepYear).padStart(4, "0")}-${String(stepMonth).padStart(2, "0")}`;
65
+ }
66
+
67
+ /**
68
+ * Band attributes for one day position. Markup and in-place band updates read
69
+ * the same map, so the two paths cannot disagree about a cell's state.
70
+ * @param {"" | "start" | "in" | "end" | "single"} position
71
+ * @returns {Record<string, boolean>}
72
+ */
73
+ function rangeAttributeMap(position) {
74
+ return {
75
+ "data-range-start": position === "start" || position === "single",
76
+ "data-range-end": position === "end" || position === "single",
77
+ "data-in-range": position === "in",
78
+ };
79
+ }
80
+
49
81
  /** @param {"" | "start" | "in" | "end" | "single"} position */
50
82
  function rangeAttributes(position) {
51
- switch (position) {
52
- case "single":
53
- return ' data-range-start="true" data-range-end="true"';
54
- case "start":
55
- return ' data-range-start="true"';
56
- case "end":
57
- return ' data-range-end="true"';
58
- case "in":
59
- return ' data-in-range="true"';
60
- default:
61
- return "";
62
- }
83
+ return Object.entries(rangeAttributeMap(position))
84
+ .filter(([, on]) => on)
85
+ .map(([name]) => ` ${name}="true"`)
86
+ .join("");
63
87
  }
64
88
 
65
89
  /** @param {"" | "start" | "in" | "end" | "single"} position */
@@ -125,6 +149,12 @@ export class DateCalendarElement extends HTMLElement {
125
149
  this._isDateDisabled = null;
126
150
  /** @type {DateRange} */
127
151
  this._highlightedRange = { start: "", end: "" };
152
+ // Bounds and preview flag the cells were last painted with. The preview
153
+ // flag changes the accessible names (a proposal drops the committed
154
+ // range-start/range-end suffix), so a repaint is due when either changes.
155
+ /** @type {DateRange} */
156
+ this._paintedRange = { start: "", end: "" };
157
+ this._paintedPreview = false;
128
158
  this._messages = getDefaultMessages();
129
159
  this._onClick = this._onClick.bind(this);
130
160
  this._onChange = this._onChange.bind(this);
@@ -262,9 +292,17 @@ export class DateCalendarElement extends HTMLElement {
262
292
 
263
293
  set highlightedRange(value) {
264
294
  const next = normalizeRange(value);
265
- if (next.start === this._highlightedRange.start && next.end === this._highlightedRange.end) return;
295
+ const preview = this.hasAttribute("data-range-preview");
296
+ // Repaint when the preview mode changed even if the bounds are identical:
297
+ // entering/leaving a preview rewrites every cell's accessible name.
298
+ if (
299
+ next.start === this._paintedRange.start &&
300
+ next.end === this._paintedRange.end &&
301
+ preview === this._paintedPreview
302
+ )
303
+ return;
266
304
  this._highlightedRange = next;
267
- if (this._connected) this.render();
305
+ if (this._connected) this._updateRangeDom();
268
306
  }
269
307
 
270
308
  /** @public */
@@ -343,7 +381,7 @@ export class DateCalendarElement extends HTMLElement {
343
381
  const attrDisplay = this.getAttribute("display") || "";
344
382
  if (attrDisplay) {
345
383
  try {
346
- this._model.setDisplay(attrDisplay);
384
+ this._model.setDisplay(clampDisplayMonth(attrDisplay, firstDay));
347
385
  } catch {
348
386
  // Invalid author markup falls back to the current model display.
349
387
  }
@@ -368,11 +406,21 @@ export class DateCalendarElement extends HTMLElement {
368
406
 
369
407
  /** @param {string} value */
370
408
  _clamp(value) {
371
- return clampDate(value, this.min, this.max);
409
+ const bounded = clampDate(value, this.min, this.max);
410
+ const month = monthKey(bounded);
411
+ const safeMonth = clampDisplayMonth(month, this.firstDay);
412
+ if (safeMonth === month) return bounded;
413
+ // The renderable window excludes this month (a calendar edge): keep the
414
+ // day-of-month where the target month has it, else fall back to its last
415
+ // day, so the focused date always exists in the rendered grid.
416
+ const day = Number(bounded.slice(8, 10)) || 1;
417
+ const candidate = addDays(`${safeMonth}-01`, day - 1);
418
+ const last = endOfMonth(safeMonth);
419
+ return compareDates(candidate, last) > 0 ? last : candidate;
372
420
  }
373
421
 
374
422
  _weeks(display = this.display) {
375
- const weeks = getMonthWeeks(display, { firstDay: this.firstDay });
423
+ const weeks = getMonthWeeks(clampDisplayMonth(display, this.firstDay), { firstDay: this.firstDay });
376
424
  if (!this.fixedWeeks || weeks.length >= 6) return weeks;
377
425
  let cursor = weeks[weeks.length - 1]?.[6] || "";
378
426
  while (weeks.length < 6 && cursor) {
@@ -386,7 +434,7 @@ export class DateCalendarElement extends HTMLElement {
386
434
 
387
435
  /** @param {HTMLElement} element @returns {string} */
388
436
  _focusTargetKey(element) {
389
- if (element.matches(".dp-month-select, .dp-year-select, .dp-year-input") && element.id) {
437
+ if (element.matches(".dp-month-select, .dp-year-input") && element.id) {
390
438
  return `#${CSS.escape(element.id)}`;
391
439
  }
392
440
  if (element.matches(".dp-nav[data-calendar-action]")) {
@@ -416,7 +464,8 @@ export class DateCalendarElement extends HTMLElement {
416
464
  const outsideBounds =
417
465
  (this.min && compareDates(date, this.min) < 0) || (this.max && compareDates(date, this.max) > 0);
418
466
  const explicitlyEnabled = state.enabled === true;
419
- // min/max > state.disabled > enabled:true lifts only isDateDisabled().
467
+ // min/max > resolved state.disabled > isDateDisabled(), unless resolved
468
+ // state.enabled === true lifts only isDateDisabled().
420
469
  const disabledByRule = explicitlyEnabled ? false : Boolean(this._isDateDisabled?.(date));
421
470
  const disabled = Boolean(outsideBounds) || Boolean(state.disabled) || disabledByRule;
422
471
  return { ...state, disabled };
@@ -490,7 +539,7 @@ export class DateCalendarElement extends HTMLElement {
490
539
  /** @param {string} display @param {{emit?:boolean}} [options] */
491
540
  _setDisplay(display, options = {}) {
492
541
  const previous = this._model.display;
493
- this._model.setDisplay(display);
542
+ this._model.setDisplay(clampDisplayMonth(display, this.firstDay));
494
543
  this._model.focused = this._clamp(this._model.focused);
495
544
  this._model.display = monthKey(this._model.focused);
496
545
  this._reflect("display", this._model.display);
@@ -511,12 +560,12 @@ export class DateCalendarElement extends HTMLElement {
511
560
 
512
561
  /** @public */
513
562
  previousMonth() {
514
- return this._setDisplay(shiftMonth(this.display, -1));
563
+ return this._setDisplay(stepMonthKey(this.display, -1) ?? this.display);
515
564
  }
516
565
 
517
566
  /** @public */
518
567
  nextMonth() {
519
- return this._setDisplay(shiftMonth(this.display, 1));
568
+ return this._setDisplay(stepMonthKey(this.display, 1) ?? this.display);
520
569
  }
521
570
 
522
571
  /** @public @param {string} date @param {{moveFocus?:boolean}} [options] */
@@ -571,14 +620,62 @@ export class DateCalendarElement extends HTMLElement {
571
620
  return false;
572
621
  }
573
622
 
623
+ /**
624
+ * Accessible name of one day cell. The band contribution is dropped while a
625
+ * preview is projected onto `highlightedRange`: a proposal must not be
626
+ * announced as a committed range start/end.
627
+ * @param {string} date @param {DateState} state
628
+ * @param {"" | "start" | "in" | "end" | "single"} position
629
+ */
630
+ _dayLabel(date, state, position) {
631
+ const rangeLabel = this.hasAttribute("data-range-preview") ? "" : rangeLabelKey(position);
632
+ const description = typeof state.description === "string" ? state.description : "";
633
+ return [
634
+ formatLongDate(date, this.locale),
635
+ description,
636
+ rangeLabel ? this._messages[rangeLabel] : "",
637
+ state.disabled ? this._messages.unavailable : "",
638
+ ]
639
+ .filter(Boolean)
640
+ .join(". ");
641
+ }
642
+
643
+ /**
644
+ * Repaint the range band over the cells already rendered. The band is
645
+ * presentation on top of the same grid, so it must never rebuild it: a full
646
+ * render would drop `renderDay()` nodes and fight the roving focus on every
647
+ * hover of a live range preview.
648
+ */
649
+ _updateRangeDom() {
650
+ this._paintedRange = { ...this._highlightedRange };
651
+ this._paintedPreview = this.hasAttribute("data-range-preview");
652
+ for (const cell of this.querySelectorAll(".dp-day[data-date]")) {
653
+ if (!(cell instanceof HTMLTableCellElement)) continue;
654
+ const date = cell.dataset.date || "";
655
+ const position = rangePosition(date, this._highlightedRange);
656
+ for (const [name, on] of Object.entries(rangeAttributeMap(position))) {
657
+ if (on) cell.setAttribute(name, "true");
658
+ else cell.removeAttribute(name);
659
+ }
660
+ cell.setAttribute("aria-label", this._dayLabel(date, this.getDateState(date), position));
661
+ }
662
+ }
663
+
574
664
  /** @public */
575
665
  focusGrid() {
576
666
  const target = this.querySelector(`.dp-day[data-date="${CSS.escape(this.focusedDate)}"]`);
577
667
  if (target instanceof HTMLElement) target.focus();
578
668
  }
579
669
 
580
- /** @param {string} date @returns {Promise<boolean>} */
581
- async _activate(date) {
670
+ /**
671
+ * Run one explicit activation: availability check, then the cancelable
672
+ * `dateactivate` seam, then selection. Click, keyboard and any other input
673
+ * device share this single path, so an application that cancels
674
+ * `dateactivate` cannot be bypassed by a new interaction.
675
+ * @public
676
+ * @param {string} date @returns {Promise<boolean>}
677
+ */
678
+ async activateDate(date) {
582
679
  // Guarantee remote state before deciding: a date clicked before the
583
680
  // source resolves must not be treated as available, and a cancelled or
584
681
  // failed load must not authorize it either.
@@ -632,7 +729,7 @@ export class DateCalendarElement extends HTMLElement {
632
729
  this._model.setFocused(this._clamp(date));
633
730
  this._reflect("display", this._model.display);
634
731
  if (previousDisplay !== this.display) this._emitDisplayChange();
635
- void this._activate(date);
732
+ void this.activateDate(date);
636
733
  }
637
734
 
638
735
  /** @param {Event} event */
@@ -642,13 +739,11 @@ export class DateCalendarElement extends HTMLElement {
642
739
  this._setDisplay(`${this.display.slice(0, 4)}-${target.value}`);
643
740
  return;
644
741
  }
645
- if (target instanceof HTMLSelectElement && target.matches(".dp-year-select")) {
646
- this._setDisplay(`${target.value}-${this.display.slice(5, 7)}`);
647
- return;
648
- }
649
742
  if (target instanceof HTMLInputElement && target.matches(".dp-year-input")) {
650
743
  const year = Number(target.value);
651
- if (!Number.isInteger(year) || year < 1 || year > 9999) {
744
+ // 9999 is a valid civil year but its December grid overflows into 10000,
745
+ // so the editable range stops one year short of the unrenderable month.
746
+ if (!Number.isInteger(year) || year < 1 || year > 9998) {
652
747
  if (this._connected) this.render(false);
653
748
  return;
654
749
  }
@@ -656,12 +751,22 @@ export class DateCalendarElement extends HTMLElement {
656
751
  }
657
752
  }
658
753
 
659
- /** @param {FocusEvent} event */
754
+ /**
755
+ * Real DOM focus is the single source of truth for `datefocus`: the model is
756
+ * synced here and the event is announced from here, so the temporary focus
757
+ * arithmetic in `_onKeyDown` cannot emit phantom events, and setting
758
+ * `focusedDate` without moving focus stays silent.
759
+ * @param {FocusEvent} event
760
+ */
660
761
  _onFocusIn(event) {
661
762
  const target = event.target;
662
763
  if (!(target instanceof HTMLTableCellElement) || !target.matches(".dp-day[data-date]")) return;
663
764
  const date = target.dataset.date || "";
664
- if (isDate(date)) this._model.focused = date;
765
+ if (!isDate(date)) return;
766
+ this._model.focused = date;
767
+ this.dispatchEvent(
768
+ new CustomEvent("datefocus", { detail: { date, state: this.getDateState(date) }, bubbles: true }),
769
+ );
665
770
  }
666
771
 
667
772
  /** @param {KeyboardEvent} event */
@@ -671,7 +776,7 @@ export class DateCalendarElement extends HTMLElement {
671
776
  const date = target.dataset.date || this.focusedDate;
672
777
  if (event.key === "Enter" || event.key === " ") {
673
778
  event.preventDefault();
674
- void this._activate(date);
779
+ void this.activateDate(date);
675
780
  return;
676
781
  }
677
782
  // Movement primitives live in CalendarModel; the DOM only syncs render/focus.
@@ -715,8 +820,10 @@ export class DateCalendarElement extends HTMLElement {
715
820
  const gridId = `${this._id}-grid`;
716
821
  const minMonth = this.min ? monthKey(this.min) : "";
717
822
  const maxMonth = this.max ? monthKey(this.max) : "";
718
- const prevDisabled = Boolean(minMonth && shiftMonth(display, -1) < minMonth);
719
- const nextDisabled = Boolean(maxMonth && shiftMonth(display, 1) > maxMonth);
823
+ const prevKey = stepMonthKey(display, -1);
824
+ const nextKey = stepMonthKey(display, 1);
825
+ const prevDisabled = Boolean(minMonth && prevKey !== null && prevKey < minMonth);
826
+ const nextDisabled = Boolean(maxMonth && nextKey !== null && nextKey > maxMonth);
720
827
  const minYearAttr = this.min ? ` min="${Number(this.min.slice(0, 4))}"` : "";
721
828
  const maxYearAttr = this.max ? ` max="${Number(this.max.slice(0, 4))}"` : "";
722
829
  const showWeeks = this.showWeekNumbers && this.firstDay === 1;
@@ -753,16 +860,7 @@ export class DateCalendarElement extends HTMLElement {
753
860
  const focused = this.focusedDate === date;
754
861
  const isToday = date === today;
755
862
  const position = rangePosition(date, this._highlightedRange);
756
- const rangeLabel = rangeLabelKey(position);
757
- const description = typeof state.description === "string" ? state.description : "";
758
- const label = [
759
- formatLongDate(date, locale),
760
- description,
761
- rangeLabel ? this._messages[rangeLabel] : "",
762
- state.disabled ? this._messages.unavailable : "",
763
- ]
764
- .filter(Boolean)
765
- .join(". ");
863
+ const label = this._dayLabel(date, state, position);
766
864
  return `<td class="dp-day" data-date="${date}"${outside ? ' data-outside-month="true"' : ""}${isToday ? ' data-today="true" aria-current="date"' : ""}${selected ? ' data-selected="true" aria-selected="true"' : ""}${rangeAttributes(position)}${state.disabled ? ' data-disabled="true" aria-disabled="true"' : ""} tabindex="${focused ? "0" : "-1"}" aria-label="${escapeHtml(label)}"><span class="dp-day-number" aria-hidden="true">${Number(date.slice(8, 10))}</span><span class="dp-day-extra" aria-hidden="true"></span></td>`;
767
865
  })
768
866
  .join("");
@@ -792,8 +890,8 @@ export class DateCalendarElement extends HTMLElement {
792
890
  <div class="dp-calendar-header">
793
891
  ${yearFirst ? yearControl : monthControl}
794
892
  ${yearFirst ? monthControl : yearControl}
795
- <button type="button" class="dp-nav dp-prev" data-calendar-action="previous" aria-label="${escapeHtml(this._messages.previousMonth)}"${prevDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m10 4-4 4 4 4"/></svg></button>
796
- <button type="button" class="dp-nav dp-next" data-calendar-action="next" aria-label="${escapeHtml(this._messages.nextMonth)}"${nextDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg></button>
893
+ <button type="button" class="dp-nav dp-prev" data-calendar-action="previous" aria-label="${escapeHtml(this._messages.previousMonth)}"${prevDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4 10 4-4 4 4"/></svg></button>
894
+ <button type="button" class="dp-nav dp-next" data-calendar-action="next" aria-label="${escapeHtml(this._messages.nextMonth)}"${nextDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4 6 4 4 4-4"/></svg></button>
797
895
  </div>
798
896
  <h2 id="${headingId}" class="dp-calendar-heading dp-visually-hidden" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
799
897
  <table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
@@ -802,6 +900,8 @@ export class DateCalendarElement extends HTMLElement {
802
900
  </table>
803
901
  </div>`;
804
902
  this._rendering = false;
903
+ this._paintedRange = { ...this._highlightedRange };
904
+ this._paintedPreview = this.hasAttribute("data-range-preview");
805
905
 
806
906
  if (focusKey) {
807
907
  queueMicrotask(() => {
package/src/date-field.js CHANGED
@@ -33,8 +33,9 @@ export class DateFieldController {
33
33
  this.onAttributesChanged = null;
34
34
  /**
35
35
  * Async availability gate for a parsed date. Returning `{ ok: false }`
36
- * marks the target unavailable. Swappable at runtime.
37
- * @type {((date: string) => Promise<{ok: boolean, message?: string}>) | null}
36
+ * marks the target unavailable. Swappable at runtime. The commit options
37
+ * are forwarded so the gate can honor `moveDisplay`.
38
+ * @type {((date: string, options?: {moveDisplay?: boolean}) => Promise<{ok: boolean, message?: string}>) | null}
38
39
  */
39
40
  this.confirm = null;
40
41
  }
@@ -117,18 +118,26 @@ export class DateFieldController {
117
118
  }
118
119
 
119
120
  /** Live text input: the raw text no longer matches the canonical value, so
120
- * the hidden ISO must not submit it. */
121
+ * the hidden ISO must not submit it. A text that formats back to the current
122
+ * canonical value (e.g. re-applied by a calendar pick) keeps the ISO value
123
+ * valid; invalidating in-flight commits first still drops stale async work. */
121
124
  handleInput() {
122
125
  const input = this.input;
123
- this._dirty = true;
126
+ const text = input.value.trim();
127
+ const canonical = this.canonical ? this.adapter.format(this.canonical) : "";
124
128
  this.dirty();
129
+ if (text === canonical) {
130
+ this._dirty = false;
131
+ if (this.hidden) this.hidden.value = this.canonical;
132
+ input.setCustomValidity("");
133
+ return;
134
+ }
135
+ this._dirty = true;
125
136
  if (this.hidden) this.hidden.value = "";
126
- const text = input.value.trim();
127
137
  if (!text) {
128
138
  input.setCustomValidity("");
129
139
  return;
130
140
  }
131
- const canonical = this.canonical ? this.adapter.format(this.canonical) : "";
132
141
  input.setCustomValidity(text === canonical ? "" : this.messages.invalidDate);
133
142
  }
134
143
 
@@ -136,9 +145,12 @@ export class DateFieldController {
136
145
  * Commit the visible text. Returns the parsed canonical date on success, the
137
146
  * empty string on a clear, or a falsy marker for invalid/stale commits. The
138
147
  * caller applies the canonical value so reflection and events stay central.
148
+ * `options` is forwarded to the `confirm` gate so a revalidation can ask it
149
+ * not to move the calendar display (source reloads must not re-home it).
150
+ * @param {{moveDisplay?: boolean}} [options]
139
151
  * @returns {Promise<{status: "ok" | "clear" | "invalid" | "stale", value?: string}>}
140
152
  */
141
- async commit() {
153
+ async commit(options = {}) {
142
154
  const input = this.input;
143
155
  const commitId = ++this._commitId;
144
156
  const text = input.value.trim();
@@ -155,7 +167,7 @@ export class DateFieldController {
155
167
  return { status: "invalid" };
156
168
  }
157
169
  if (this.confirm) {
158
- const result = await this.confirm(parsed);
170
+ const result = await this.confirm(parsed, options);
159
171
  if (commitId !== this._commitId) return { status: "stale" };
160
172
  if (!result?.ok) {
161
173
  if (this.hidden) this.hidden.value = "";