@lekoala/date-picker 0.3.0 → 0.5.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.
Files changed (41) hide show
  1. package/README.md +281 -263
  2. package/custom-elements.json +30 -0
  3. package/dist/date-picker.css +51 -46
  4. package/dist/date-picker.js +685 -204
  5. package/dist/date-picker.min.css +1 -1
  6. package/dist/date-picker.min.js +13 -13
  7. package/dist/date-picker.standalone.min.js +65 -60
  8. package/dist/types/control-classes.d.ts +30 -0
  9. package/dist/types/control-classes.d.ts.map +1 -0
  10. package/dist/types/date-calendar.d.ts +49 -3
  11. package/dist/types/date-calendar.d.ts.map +1 -1
  12. package/dist/types/date-field.d.ts +12 -4
  13. package/dist/types/date-field.d.ts.map +1 -1
  14. package/dist/types/date-picker.d.ts +106 -9
  15. package/dist/types/date-picker.d.ts.map +1 -1
  16. package/dist/types/date-range.d.ts +102 -20
  17. package/dist/types/date-range.d.ts.map +1 -1
  18. package/dist/types/date.d.ts +14 -2
  19. package/dist/types/date.d.ts.map +1 -1
  20. package/dist/types/index.d.ts +1 -0
  21. package/dist/types/index.d.ts.map +1 -1
  22. package/dist/types/intl.d.ts.map +1 -1
  23. package/dist/types/source.d.ts +1 -0
  24. package/dist/types/source.d.ts.map +1 -1
  25. package/docs/ACCESSIBILITY.md +6 -2
  26. package/docs/API.md +430 -358
  27. package/docs/DECISIONS.md +68 -49
  28. package/docs/MIGRATION-FLATPICKR.md +1 -1
  29. package/docs/TESTING.md +1 -1
  30. package/docs/USE_CASES.md +156 -141
  31. package/package.json +8 -8
  32. package/src/control-classes.js +50 -0
  33. package/src/date-calendar.js +188 -54
  34. package/src/date-field.js +9 -5
  35. package/src/date-picker.css +51 -46
  36. package/src/date-picker.js +392 -83
  37. package/src/date-range.js +184 -30
  38. package/src/date.js +57 -3
  39. package/src/index.js +1 -0
  40. package/src/intl.js +52 -17
  41. package/src/source.js +14 -4
@@ -1,13 +1,15 @@
1
1
  import { CalendarModel } from "./calendar-model.js";
2
+ import { getDefaultControlClasses, resolveControlClasses, sameControlClasses } from "./control-classes.js";
2
3
  import {
3
4
  addDays,
4
5
  clampDate,
6
+ clampDisplayMonth,
5
7
  compareDates,
8
+ endOfMonth,
6
9
  getMonthWeeks,
7
10
  isDate,
8
11
  isoWeekNumber,
9
12
  monthKey,
10
- shiftMonth,
11
13
  todayISO,
12
14
  } from "./date.js";
13
15
  import { normalizeRange, rangePosition } from "./date-range.js";
@@ -46,20 +48,43 @@ function yearOf(month) {
46
48
  return Number(month.slice(0, 4));
47
49
  }
48
50
 
51
+ /**
52
+ * Step a `YYYY-MM` key by ±1, or `null` when the step leaves the canonical
53
+ * 0001-01..9999-12 window. Pure arithmetic, so an out-of-range intermediate
54
+ * date is never constructed and the calendar edges cannot throw.
55
+ * @param {string} month
56
+ * @param {number} amount
57
+ * @returns {string | null}
58
+ */
59
+ function stepMonthKey(month, amount) {
60
+ const [year, index] = month.split("-").map(Number);
61
+ const absolute = year * 12 + (index - 1) + amount;
62
+ const stepYear = Math.floor(absolute / 12);
63
+ const stepMonth = (((absolute % 12) + 12) % 12) + 1;
64
+ if (stepYear < 1 || stepYear > 9999) return null;
65
+ return `${String(stepYear).padStart(4, "0")}-${String(stepMonth).padStart(2, "0")}`;
66
+ }
67
+
68
+ /**
69
+ * Band attributes for one day position. Markup and in-place band updates read
70
+ * the same map, so the two paths cannot disagree about a cell's state.
71
+ * @param {"" | "start" | "in" | "end" | "single"} position
72
+ * @returns {Record<string, boolean>}
73
+ */
74
+ function rangeAttributeMap(position) {
75
+ return {
76
+ "data-range-start": position === "start" || position === "single",
77
+ "data-range-end": position === "end" || position === "single",
78
+ "data-in-range": position === "in",
79
+ };
80
+ }
81
+
49
82
  /** @param {"" | "start" | "in" | "end" | "single"} position */
50
83
  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
- }
84
+ return Object.entries(rangeAttributeMap(position))
85
+ .filter(([, on]) => on)
86
+ .map(([name]) => ` ${name}="true"`)
87
+ .join("");
63
88
  }
64
89
 
65
90
  /** @param {"" | "start" | "in" | "end" | "single"} position */
@@ -125,7 +150,16 @@ export class DateCalendarElement extends HTMLElement {
125
150
  this._isDateDisabled = null;
126
151
  /** @type {DateRange} */
127
152
  this._highlightedRange = { start: "", end: "" };
153
+ // Bounds and preview flag the cells were last painted with. The preview
154
+ // flag changes the accessible names (a proposal drops the committed
155
+ // range-start/range-end suffix), so a repaint is due when either changes.
156
+ /** @type {DateRange} */
157
+ this._paintedRange = { start: "", end: "" };
158
+ this._paintedPreview = false;
159
+ /** @type {Map<string, DateState>} States of the current rendered grid. */
160
+ this._renderedStates = new Map();
128
161
  this._messages = getDefaultMessages();
162
+ this._controlClasses = getDefaultControlClasses();
129
163
  this._onClick = this._onClick.bind(this);
130
164
  this._onChange = this._onChange.bind(this);
131
165
  this._onKeyDown = this._onKeyDown.bind(this);
@@ -262,9 +296,17 @@ export class DateCalendarElement extends HTMLElement {
262
296
 
263
297
  set highlightedRange(value) {
264
298
  const next = normalizeRange(value);
265
- if (next.start === this._highlightedRange.start && next.end === this._highlightedRange.end) return;
299
+ const preview = this.hasAttribute("data-range-preview");
300
+ // Repaint when the preview mode changed even if the bounds are identical:
301
+ // entering/leaving a preview rewrites every cell's accessible name.
302
+ if (
303
+ next.start === this._paintedRange.start &&
304
+ next.end === this._paintedRange.end &&
305
+ preview === this._paintedPreview
306
+ )
307
+ return;
266
308
  this._highlightedRange = next;
267
- if (this._connected) this.render();
309
+ if (this._connected) this._updateRangeDom();
268
310
  }
269
311
 
270
312
  /** @public */
@@ -285,6 +327,23 @@ export class DateCalendarElement extends HTMLElement {
285
327
  this.toggleAttribute("show-week-numbers", Boolean(value));
286
328
  }
287
329
 
330
+ /**
331
+ * Author classes for header controls; merged with global defaults.
332
+ * @public
333
+ * @returns {import("./control-classes.js").ControlClasses}
334
+ */
335
+ get controlClasses() {
336
+ return { ...this._controlClasses };
337
+ }
338
+
339
+ /** @param {Partial<import("./control-classes.js").ControlClasses> | null} value */
340
+ set controlClasses(value) {
341
+ const classes = resolveControlClasses(value);
342
+ if (sameControlClasses(classes, this._controlClasses)) return;
343
+ this._controlClasses = classes;
344
+ if (this._connected) this.render();
345
+ }
346
+
288
347
  /** @public */
289
348
  get messages() {
290
349
  return this._messages;
@@ -343,7 +402,7 @@ export class DateCalendarElement extends HTMLElement {
343
402
  const attrDisplay = this.getAttribute("display") || "";
344
403
  if (attrDisplay) {
345
404
  try {
346
- this._model.setDisplay(attrDisplay);
405
+ this._model.setDisplay(clampDisplayMonth(attrDisplay, firstDay));
347
406
  } catch {
348
407
  // Invalid author markup falls back to the current model display.
349
408
  }
@@ -368,20 +427,24 @@ export class DateCalendarElement extends HTMLElement {
368
427
 
369
428
  /** @param {string} value */
370
429
  _clamp(value) {
371
- return clampDate(value, this.min, this.max);
430
+ const bounded = clampDate(value, this.min, this.max);
431
+ const month = monthKey(bounded);
432
+ const safeMonth = clampDisplayMonth(month, this.firstDay);
433
+ if (safeMonth === month) return bounded;
434
+ // The renderable window excludes this month (a calendar edge): keep the
435
+ // day-of-month where the target month has it, else fall back to its last
436
+ // day, so the focused date always exists in the rendered grid.
437
+ const day = Number(bounded.slice(8, 10)) || 1;
438
+ const candidate = addDays(`${safeMonth}-01`, day - 1);
439
+ const last = endOfMonth(safeMonth);
440
+ return compareDates(candidate, last) > 0 ? last : candidate;
372
441
  }
373
442
 
374
443
  _weeks(display = this.display) {
375
- const weeks = getMonthWeeks(display, { firstDay: this.firstDay });
376
- if (!this.fixedWeeks || weeks.length >= 6) return weeks;
377
- let cursor = weeks[weeks.length - 1]?.[6] || "";
378
- while (weeks.length < 6 && cursor) {
379
- const week = [];
380
- for (let i = 1; i <= 7; i++) week.push(addDays(cursor, i));
381
- weeks.push(week);
382
- cursor = week[6];
383
- }
384
- return weeks;
444
+ return getMonthWeeks(clampDisplayMonth(display, this.firstDay), {
445
+ firstDay: this.firstDay,
446
+ fixedWeeks: this.fixedWeeks,
447
+ });
385
448
  }
386
449
 
387
450
  /** @param {HTMLElement} element @returns {string} */
@@ -491,7 +554,7 @@ export class DateCalendarElement extends HTMLElement {
491
554
  /** @param {string} display @param {{emit?:boolean}} [options] */
492
555
  _setDisplay(display, options = {}) {
493
556
  const previous = this._model.display;
494
- this._model.setDisplay(display);
557
+ this._model.setDisplay(clampDisplayMonth(display, this.firstDay));
495
558
  this._model.focused = this._clamp(this._model.focused);
496
559
  this._model.display = monthKey(this._model.focused);
497
560
  this._reflect("display", this._model.display);
@@ -512,12 +575,12 @@ export class DateCalendarElement extends HTMLElement {
512
575
 
513
576
  /** @public */
514
577
  previousMonth() {
515
- return this._setDisplay(shiftMonth(this.display, -1));
578
+ return this._setDisplay(stepMonthKey(this.display, -1) ?? this.display);
516
579
  }
517
580
 
518
581
  /** @public */
519
582
  nextMonth() {
520
- return this._setDisplay(shiftMonth(this.display, 1));
583
+ return this._setDisplay(stepMonthKey(this.display, 1) ?? this.display);
521
584
  }
522
585
 
523
586
  /** @public @param {string} date @param {{moveFocus?:boolean}} [options] */
@@ -572,14 +635,72 @@ export class DateCalendarElement extends HTMLElement {
572
635
  return false;
573
636
  }
574
637
 
638
+ /**
639
+ * Accessible name of one day cell. The band contribution is dropped while a
640
+ * preview is projected onto `highlightedRange`: a proposal must not be
641
+ * announced as a committed range start/end.
642
+ * @param {string} date @param {DateState} state
643
+ * @param {"" | "start" | "in" | "end" | "single"} position
644
+ */
645
+ _dayLabel(date, state, position) {
646
+ const rangeLabel = this.hasAttribute("data-range-preview") ? "" : rangeLabelKey(position);
647
+ const description = typeof state.description === "string" ? state.description : "";
648
+ return [
649
+ formatLongDate(date, this.locale),
650
+ description,
651
+ rangeLabel ? this._messages[rangeLabel] : "",
652
+ state.disabled ? this._messages.unavailable : "",
653
+ ]
654
+ .filter(Boolean)
655
+ .join(". ");
656
+ }
657
+
658
+ /**
659
+ * Repaint the range band over the cells already rendered. The band is
660
+ * presentation on top of the same grid, so it must never rebuild it: a full
661
+ * render would drop `renderDay()` nodes and fight the roving focus on every
662
+ * hover of a live range preview.
663
+ */
664
+ _updateRangeDom() {
665
+ const previousRange = this._paintedRange;
666
+ const previousPreview = this._paintedPreview;
667
+ this._paintedRange = { ...this._highlightedRange };
668
+ this._paintedPreview = this.hasAttribute("data-range-preview");
669
+ for (const cell of this.querySelectorAll(".dp-day[data-date]")) {
670
+ if (!(cell instanceof HTMLTableCellElement)) continue;
671
+ const date = cell.dataset.date || "";
672
+ const position = rangePosition(date, this._highlightedRange);
673
+ if (position === rangePosition(date, previousRange) && previousPreview === this._paintedPreview)
674
+ continue;
675
+ for (const [name, on] of Object.entries(rangeAttributeMap(position))) {
676
+ if (on) cell.setAttribute(name, "true");
677
+ else cell.removeAttribute(name);
678
+ }
679
+ // A moving preview changes only the band. Its accessible labels always
680
+ // omit range roles; entering/leaving preview still updates those labels.
681
+ if (!previousPreview || !this._paintedPreview) {
682
+ const state = this._renderedStates.get(date) || {};
683
+ const label = this._dayLabel(date, state, position);
684
+ if (cell.getAttribute("aria-label") !== label) cell.setAttribute("aria-label", label);
685
+ }
686
+ }
687
+ }
688
+
575
689
  /** @public */
576
690
  focusGrid() {
577
691
  const target = this.querySelector(`.dp-day[data-date="${CSS.escape(this.focusedDate)}"]`);
578
692
  if (target instanceof HTMLElement) target.focus();
579
693
  }
580
694
 
581
- /** @param {string} date @returns {Promise<boolean>} */
582
- async _activate(date) {
695
+ /**
696
+ * Run one explicit activation: availability check, then the cancelable
697
+ * `dateactivate` seam, then selection. Click, keyboard and any other input
698
+ * device share this single path, so an application that cancels
699
+ * `dateactivate` cannot be bypassed by a new interaction.
700
+ * @public
701
+ * @param {string} date @returns {Promise<boolean>}
702
+ */
703
+ async activateDate(date) {
583
704
  // Guarantee remote state before deciding: a date clicked before the
584
705
  // source resolves must not be treated as available, and a cancelled or
585
706
  // failed load must not authorize it either.
@@ -633,7 +754,7 @@ export class DateCalendarElement extends HTMLElement {
633
754
  this._model.setFocused(this._clamp(date));
634
755
  this._reflect("display", this._model.display);
635
756
  if (previousDisplay !== this.display) this._emitDisplayChange();
636
- void this._activate(date);
757
+ void this.activateDate(date);
637
758
  }
638
759
 
639
760
  /** @param {Event} event */
@@ -645,7 +766,9 @@ export class DateCalendarElement extends HTMLElement {
645
766
  }
646
767
  if (target instanceof HTMLInputElement && target.matches(".dp-year-input")) {
647
768
  const year = Number(target.value);
648
- if (!Number.isInteger(year) || year < 1 || year > 9999) {
769
+ // 9999 is a valid civil year but its December grid overflows into 10000,
770
+ // so the editable range stops one year short of the unrenderable month.
771
+ if (!Number.isInteger(year) || year < 1 || year > 9998) {
649
772
  if (this._connected) this.render(false);
650
773
  return;
651
774
  }
@@ -653,12 +776,22 @@ export class DateCalendarElement extends HTMLElement {
653
776
  }
654
777
  }
655
778
 
656
- /** @param {FocusEvent} event */
779
+ /**
780
+ * Real DOM focus is the single source of truth for `datefocus`: the model is
781
+ * synced here and the event is announced from here, so the temporary focus
782
+ * arithmetic in `_onKeyDown` cannot emit phantom events, and setting
783
+ * `focusedDate` without moving focus stays silent.
784
+ * @param {FocusEvent} event
785
+ */
657
786
  _onFocusIn(event) {
658
787
  const target = event.target;
659
788
  if (!(target instanceof HTMLTableCellElement) || !target.matches(".dp-day[data-date]")) return;
660
789
  const date = target.dataset.date || "";
661
- if (isDate(date)) this._model.focused = date;
790
+ if (!isDate(date)) return;
791
+ this._model.focused = date;
792
+ this.dispatchEvent(
793
+ new CustomEvent("datefocus", { detail: { date, state: this.getDateState(date) }, bubbles: true }),
794
+ );
662
795
  }
663
796
 
664
797
  /** @param {KeyboardEvent} event */
@@ -668,7 +801,7 @@ export class DateCalendarElement extends HTMLElement {
668
801
  const date = target.dataset.date || this.focusedDate;
669
802
  if (event.key === "Enter" || event.key === " ") {
670
803
  event.preventDefault();
671
- void this._activate(date);
804
+ void this.activateDate(date);
672
805
  return;
673
806
  }
674
807
  // Movement primitives live in CalendarModel; the DOM only syncs render/focus.
@@ -712,8 +845,10 @@ export class DateCalendarElement extends HTMLElement {
712
845
  const gridId = `${this._id}-grid`;
713
846
  const minMonth = this.min ? monthKey(this.min) : "";
714
847
  const maxMonth = this.max ? monthKey(this.max) : "";
715
- const prevDisabled = Boolean(minMonth && shiftMonth(display, -1) < minMonth);
716
- const nextDisabled = Boolean(maxMonth && shiftMonth(display, 1) > maxMonth);
848
+ const prevKey = stepMonthKey(display, -1);
849
+ const nextKey = stepMonthKey(display, 1);
850
+ const prevDisabled = Boolean(minMonth && prevKey !== null && prevKey < minMonth);
851
+ const nextDisabled = Boolean(maxMonth && nextKey !== null && nextKey > maxMonth);
717
852
  const minYearAttr = this.min ? ` min="${Number(this.min.slice(0, 4))}"` : "";
718
853
  const maxYearAttr = this.max ? ` max="${Number(this.max.slice(0, 4))}"` : "";
719
854
  const showWeeks = this.showWeekNumbers && this.firstDay === 1;
@@ -737,6 +872,7 @@ export class DateCalendarElement extends HTMLElement {
737
872
  )
738
873
  .join("");
739
874
 
875
+ this._renderedStates.clear();
740
876
  const rows = weeks
741
877
  .map((week) => {
742
878
  const weekNumber = showWeeks
@@ -745,21 +881,13 @@ export class DateCalendarElement extends HTMLElement {
745
881
  const cells = week
746
882
  .map((date) => {
747
883
  const state = this.getDateState(date);
884
+ this._renderedStates.set(date, state);
748
885
  const outside = monthKey(date) !== display;
749
886
  const selected = this.value === date;
750
887
  const focused = this.focusedDate === date;
751
888
  const isToday = date === today;
752
889
  const position = rangePosition(date, this._highlightedRange);
753
- const rangeLabel = rangeLabelKey(position);
754
- const description = typeof state.description === "string" ? state.description : "";
755
- const label = [
756
- formatLongDate(date, locale),
757
- description,
758
- rangeLabel ? this._messages[rangeLabel] : "",
759
- state.disabled ? this._messages.unavailable : "",
760
- ]
761
- .filter(Boolean)
762
- .join(". ");
890
+ const label = this._dayLabel(date, state, position);
763
891
  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>`;
764
892
  })
765
893
  .join("");
@@ -778,10 +906,14 @@ export class DateCalendarElement extends HTMLElement {
778
906
  // select via `data-year-first`.
779
907
  const yearFirst = monthYearOrder(locale)[0] === "year";
780
908
  this.toggleAttribute("data-year-first", yearFirst);
909
+ const classes = this._controlClasses;
910
+ const monthClass = escapeHtml(classes.month || "dp-skin");
911
+ const yearClass = escapeHtml(classes.year || "dp-skin");
912
+ const navClass = escapeHtml(classes.nav || "dp-skin");
781
913
  const monthControl = `<label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
782
- <select id="${this._id}-month" class="dp-month-select" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>`;
914
+ <select id="${this._id}-month" class="dp-month-select ${monthClass}" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>`;
783
915
  const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
784
- <input id="${this._id}-year" class="dp-year-input" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">`;
916
+ <input id="${this._id}-year" class="dp-year-input ${yearClass}" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">`;
785
917
 
786
918
  this._rendering = true;
787
919
  this.innerHTML = `
@@ -789,8 +921,8 @@ export class DateCalendarElement extends HTMLElement {
789
921
  <div class="dp-calendar-header">
790
922
  ${yearFirst ? yearControl : monthControl}
791
923
  ${yearFirst ? monthControl : yearControl}
792
- <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>
793
- <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>
924
+ <button type="button" class="dp-nav dp-prev ${navClass}" 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>
925
+ <button type="button" class="dp-nav dp-next ${navClass}" 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>
794
926
  </div>
795
927
  <h2 id="${headingId}" class="dp-calendar-heading dp-visually-hidden" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
796
928
  <table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
@@ -799,6 +931,8 @@ export class DateCalendarElement extends HTMLElement {
799
931
  </table>
800
932
  </div>`;
801
933
  this._rendering = false;
934
+ this._paintedRange = { ...this._highlightedRange };
935
+ this._paintedPreview = this.hasAttribute("data-range-preview");
802
936
 
803
937
  if (focusKey) {
804
938
  queueMicrotask(() => {
@@ -822,7 +956,7 @@ export class DateCalendarElement extends HTMLElement {
822
956
  const date = cell.dataset.date || "";
823
957
  const extra = cell.querySelector(".dp-day-extra");
824
958
  if (!(extra instanceof HTMLElement)) continue;
825
- const content = this._renderDay(date, this.getDateState(date));
959
+ const content = this._renderDay(date, this._renderedStates.get(date) || {});
826
960
  if (content instanceof Node) extra.append(content);
827
961
  else if (content !== undefined && content !== null) extra.textContent = String(content);
828
962
  }
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
  }
@@ -137,16 +138,19 @@ export class DateFieldController {
137
138
  input.setCustomValidity("");
138
139
  return;
139
140
  }
140
- input.setCustomValidity(text === canonical ? "" : this.messages.invalidDate);
141
+ input.setCustomValidity(this.messages.invalidDate);
141
142
  }
142
143
 
143
144
  /**
144
145
  * Commit the visible text. Returns the parsed canonical date on success, the
145
146
  * empty string on a clear, or a falsy marker for invalid/stale commits. The
146
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]
147
151
  * @returns {Promise<{status: "ok" | "clear" | "invalid" | "stale", value?: string}>}
148
152
  */
149
- async commit() {
153
+ async commit(options = {}) {
150
154
  const input = this.input;
151
155
  const commitId = ++this._commitId;
152
156
  const text = input.value.trim();
@@ -163,7 +167,7 @@ export class DateFieldController {
163
167
  return { status: "invalid" };
164
168
  }
165
169
  if (this.confirm) {
166
- const result = await this.confirm(parsed);
170
+ const result = await this.confirm(parsed, options);
167
171
  if (commitId !== this._commitId) return { status: "stale" };
168
172
  if (!result?.ok) {
169
173
  if (this.hidden) this.hidden.value = "";
@@ -6,6 +6,8 @@
6
6
  --dp-hover: color-mix(in srgb, var(--dp-fg) 5%, var(--dp-bg));
7
7
  --dp-accent: #2563eb;
8
8
  --dp-accent-fg: #fff;
9
+ --dp-focus: var(--dp-accent);
10
+ --dp-focus-width: 2px;
9
11
  --dp-radius: 0.5rem;
10
12
  --dp-day-size: 2.75rem;
11
13
  --dp-gap: 0.375rem;
@@ -44,7 +46,7 @@ date-calendar {
44
46
 
45
47
  .dp-calendar-header {
46
48
  display: grid;
47
- grid-template-columns: minmax(0, 1fr) auto auto auto;
49
+ grid-template-columns: minmax(0, 1fr) var(--dp-year-inline-size) auto auto;
48
50
  /* Stretch (not center): once a UA natural height exceeds
49
51
  --dp-header-control-size, the row grows to the tallest control and the
50
52
  others stretch to match, so month/year/nav stay equal. */
@@ -60,12 +62,16 @@ date-calendar {
60
62
  track stays under the month select so the fixed-width year input never
61
63
  stretches. Visual mirroring itself is left to the inherited `dir`. */
62
64
  date-calendar[data-year-first] .dp-calendar-header {
63
- grid-template-columns: auto minmax(0, 1fr) auto auto;
65
+ grid-template-columns: var(--dp-year-inline-size) minmax(0, 1fr) auto auto;
64
66
  }
65
67
 
66
68
  .dp-calendar-header :where(button, select, input) {
67
- min-block-size: var(--dp-header-control-size);
68
69
  box-sizing: border-box;
70
+ min-inline-size: 0;
71
+ }
72
+
73
+ .dp-calendar-header .dp-skin {
74
+ min-block-size: var(--dp-header-control-size);
69
75
  /* Shared vertical metrics: UA default padding/line-height differ per control
70
76
  (up to ~13px apart), which only shows once --dp-header-control-size drops
71
77
  below content size. */
@@ -78,8 +84,7 @@ date-calendar[data-year-first] .dp-calendar-header {
78
84
  line-height: 1.2;
79
85
  }
80
86
 
81
- .dp-calendar-header input {
82
- min-inline-size: 0;
87
+ .dp-calendar-header input.dp-skin {
83
88
  padding-inline: 0.45rem;
84
89
  }
85
90
 
@@ -89,11 +94,13 @@ date-calendar[data-year-first] .dp-calendar-header {
89
94
  control and the others stretch to match. */
90
95
  .dp-month-select {
91
96
  inline-size: 100%;
92
- min-inline-size: 0;
93
97
  /* Long month names in a narrow track (compact consumers, long locales)
94
98
  would paint under the caret: truncate with an ellipsis instead. */
95
99
  overflow: hidden;
96
100
  text-overflow: ellipsis;
101
+ }
102
+
103
+ .dp-month-select.dp-skin {
97
104
  appearance: none;
98
105
  padding-inline: 0.45rem 2rem;
99
106
  background-image:
@@ -109,18 +116,18 @@ date-calendar[data-year-first] .dp-calendar-header {
109
116
  /* Inherited RTL mirrors the header layout via the grid. The logical
110
117
  `padding-inline` already flips, so only the physically-positioned caret has
111
118
  to follow it to the other side. */
112
- [dir="rtl"] .dp-month-select,
113
- html[dir="rtl"] .dp-month-select {
119
+ [dir="rtl"] .dp-month-select.dp-skin,
120
+ html[dir="rtl"] .dp-month-select.dp-skin {
114
121
  background-position:
115
122
  0.9rem 50%,
116
123
  0.65rem 50%;
117
124
  }
118
125
 
119
126
  .dp-year-input {
120
- inline-size: var(--dp-year-inline-size);
127
+ inline-size: 100%;
121
128
  }
122
129
 
123
- .dp-nav {
130
+ .dp-nav.dp-skin {
124
131
  display: grid;
125
132
  place-items: center;
126
133
  inline-size: var(--dp-header-control-size);
@@ -137,30 +144,23 @@ html[dir="rtl"] .dp-month-select {
137
144
  pointer-events: none;
138
145
  }
139
146
 
140
- /* Inherited RTL mirrors the header layout via the grid; mirror the nav glyphs
141
- so previous/next keep their visual sense. Actions stay chronological: no
142
- button swap, no keyboard remapping. */
143
- [dir="rtl"] .dp-nav svg,
144
- html[dir="rtl"] .dp-nav svg {
145
- transform: scaleX(-1);
146
- }
147
-
148
- .dp-calendar-header :where(button, select, input):disabled,
147
+ .dp-calendar-header .dp-skin:disabled,
149
148
  .dp-picker-button:disabled {
150
149
  cursor: not-allowed;
151
150
  opacity: 0.45;
152
151
  }
153
152
 
154
- .dp-nav:hover:not(:disabled) {
153
+ .dp-nav.dp-skin:hover:not(:disabled) {
155
154
  background: var(--dp-hover);
156
155
  }
157
156
 
158
- /* Shared control focus: accent border + light halo. Day cells keep their
159
- dedicated outline ring below; the picker trigger has its own inner ring. */
160
- .dp-calendar-header :where(button, select, input):focus-visible {
161
- outline: none;
162
- border-color: var(--dp-accent);
163
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--dp-accent) 22%, transparent);
157
+ /* Inner outlines survive forced colors and keep focus inside each control.
158
+ The picker trigger uses a tighter inset around its icon below. */
159
+ .dp-calendar-header .dp-skin:focus-visible,
160
+ date-picker > input:not([type="hidden"]):focus-visible,
161
+ .dp-day:focus-visible {
162
+ outline: var(--dp-focus-width) solid var(--dp-focus);
163
+ outline-offset: calc(var(--dp-focus-width) * -1);
164
164
  }
165
165
 
166
166
  .dp-day:hover:not([data-disabled="true"]):not([data-selected="true"]):not([data-range-start="true"]):not(
@@ -205,15 +205,6 @@ html[dir="rtl"] .dp-nav svg {
205
205
  font-size: 0.875rem;
206
206
  }
207
207
 
208
- /* Inner ring: keyboard focus stays inside the cell instead of reading as a
209
- second selected state or bleeding onto a neighbouring range band. It is an
210
- `outline` (not an inset box-shadow) because forced colors computes
211
- box-shadow to `none`, which would erase the keyboard indicator entirely. */
212
- .dp-day:focus-visible {
213
- outline: 2px solid var(--dp-accent);
214
- outline-offset: -2px;
215
- }
216
-
217
208
  /* On an accent-filled cell the ring inverts so it stays readable. */
218
209
  .dp-day:is([data-selected="true"], [data-range-start="true"], [data-range-end="true"]):focus-visible {
219
210
  outline-color: var(--dp-accent-fg);
@@ -275,6 +266,28 @@ html[dir="rtl"] .dp-nav svg {
275
266
  border-end-end-radius: var(--dp-day-radius);
276
267
  }
277
268
 
269
+ /* Projected band: the range the next commit would produce. It reads as one
270
+ step short of the committed band, so a hovered, keyboard or dragged preview
271
+ is never mistaken for the selection itself. */
272
+ [data-range-preview] .dp-day:is([data-range-start="true"], [data-range-end="true"]) {
273
+ background: color-mix(in srgb, var(--dp-accent) 58%, var(--dp-bg));
274
+ }
275
+
276
+ [data-range-preview] .dp-day[data-in-range="true"] {
277
+ background: color-mix(in srgb, var(--dp-accent) 11%, var(--dp-bg));
278
+ }
279
+
280
+ /* Endpoint handles are draggable only while the committed range is complete
281
+ and its two ends sit on different days. */
282
+ [data-range-drag] .dp-day:is([data-range-start="true"], [data-range-end="true"]) {
283
+ cursor: grab;
284
+ }
285
+
286
+ [data-range-drag="active"],
287
+ [data-range-drag="active"] .dp-day {
288
+ cursor: grabbing;
289
+ }
290
+
278
291
  .dp-day-number {
279
292
  display: block;
280
293
  font-variant-numeric: tabular-nums;
@@ -320,14 +333,6 @@ date-picker {
320
333
  max-inline-size: 100%;
321
334
  }
322
335
 
323
- /* Visible focus is normalized to --dp-accent so the component holds together
324
- without a consumer stylesheet; the field's own look stays consumer-owned. */
325
- date-picker > input:not([type="hidden"]):focus-visible {
326
- border-color: var(--dp-accent);
327
- outline: none;
328
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--dp-accent) 20%, transparent);
329
- }
330
-
331
336
  /* Every authored control is a grid item. */
332
337
  date-picker > input:not([type="hidden"]) {
333
338
  min-inline-size: 0;
@@ -368,7 +373,7 @@ date-picker > input:not([type="hidden"]):not([type="time"]) {
368
373
  field already shows its own ring through :focus-visible). Same radius as
369
374
  everywhere else: no bespoke shape. */
370
375
  .dp-picker-button:focus-visible {
371
- outline: 2px solid var(--dp-accent);
376
+ outline: var(--dp-focus-width) solid var(--dp-focus);
372
377
  outline-offset: -5px;
373
378
  border-radius: var(--dp-radius);
374
379
  }
@@ -446,8 +451,8 @@ date-picker[range] > .dp-picker-button[data-endpoint="end"] {
446
451
  @media (forced-colors: active) {
447
452
  /* The author-drawn caret is a gradient and disappears here: hand the
448
453
  control back to the browser instead of trying to rebuild it. */
449
- .dp-month-select,
450
- [dir="rtl"] .dp-month-select {
454
+ .dp-month-select.dp-skin,
455
+ [dir="rtl"] .dp-month-select.dp-skin {
451
456
  appearance: auto;
452
457
  background-image: none;
453
458
  padding-inline: 0.45rem;