@lekoala/date-picker 0.4.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.
@@ -1,4 +1,5 @@
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,
@@ -155,7 +156,10 @@ export class DateCalendarElement extends HTMLElement {
155
156
  /** @type {DateRange} */
156
157
  this._paintedRange = { start: "", end: "" };
157
158
  this._paintedPreview = false;
159
+ /** @type {Map<string, DateState>} States of the current rendered grid. */
160
+ this._renderedStates = new Map();
158
161
  this._messages = getDefaultMessages();
162
+ this._controlClasses = getDefaultControlClasses();
159
163
  this._onClick = this._onClick.bind(this);
160
164
  this._onChange = this._onChange.bind(this);
161
165
  this._onKeyDown = this._onKeyDown.bind(this);
@@ -323,6 +327,23 @@ export class DateCalendarElement extends HTMLElement {
323
327
  this.toggleAttribute("show-week-numbers", Boolean(value));
324
328
  }
325
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
+
326
347
  /** @public */
327
348
  get messages() {
328
349
  return this._messages;
@@ -420,16 +441,10 @@ export class DateCalendarElement extends HTMLElement {
420
441
  }
421
442
 
422
443
  _weeks(display = this.display) {
423
- const weeks = getMonthWeeks(clampDisplayMonth(display, this.firstDay), { firstDay: this.firstDay });
424
- if (!this.fixedWeeks || weeks.length >= 6) return weeks;
425
- let cursor = weeks[weeks.length - 1]?.[6] || "";
426
- while (weeks.length < 6 && cursor) {
427
- const week = [];
428
- for (let i = 1; i <= 7; i++) week.push(addDays(cursor, i));
429
- weeks.push(week);
430
- cursor = week[6];
431
- }
432
- return weeks;
444
+ return getMonthWeeks(clampDisplayMonth(display, this.firstDay), {
445
+ firstDay: this.firstDay,
446
+ fixedWeeks: this.fixedWeeks,
447
+ });
433
448
  }
434
449
 
435
450
  /** @param {HTMLElement} element @returns {string} */
@@ -647,17 +662,27 @@ export class DateCalendarElement extends HTMLElement {
647
662
  * hover of a live range preview.
648
663
  */
649
664
  _updateRangeDom() {
665
+ const previousRange = this._paintedRange;
666
+ const previousPreview = this._paintedPreview;
650
667
  this._paintedRange = { ...this._highlightedRange };
651
668
  this._paintedPreview = this.hasAttribute("data-range-preview");
652
669
  for (const cell of this.querySelectorAll(".dp-day[data-date]")) {
653
670
  if (!(cell instanceof HTMLTableCellElement)) continue;
654
671
  const date = cell.dataset.date || "";
655
672
  const position = rangePosition(date, this._highlightedRange);
673
+ if (position === rangePosition(date, previousRange) && previousPreview === this._paintedPreview)
674
+ continue;
656
675
  for (const [name, on] of Object.entries(rangeAttributeMap(position))) {
657
676
  if (on) cell.setAttribute(name, "true");
658
677
  else cell.removeAttribute(name);
659
678
  }
660
- cell.setAttribute("aria-label", this._dayLabel(date, this.getDateState(date), position));
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
+ }
661
686
  }
662
687
  }
663
688
 
@@ -847,6 +872,7 @@ export class DateCalendarElement extends HTMLElement {
847
872
  )
848
873
  .join("");
849
874
 
875
+ this._renderedStates.clear();
850
876
  const rows = weeks
851
877
  .map((week) => {
852
878
  const weekNumber = showWeeks
@@ -855,6 +881,7 @@ export class DateCalendarElement extends HTMLElement {
855
881
  const cells = week
856
882
  .map((date) => {
857
883
  const state = this.getDateState(date);
884
+ this._renderedStates.set(date, state);
858
885
  const outside = monthKey(date) !== display;
859
886
  const selected = this.value === date;
860
887
  const focused = this.focusedDate === date;
@@ -879,10 +906,14 @@ export class DateCalendarElement extends HTMLElement {
879
906
  // select via `data-year-first`.
880
907
  const yearFirst = monthYearOrder(locale)[0] === "year";
881
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");
882
913
  const monthControl = `<label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
883
- <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>`;
884
915
  const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
885
- <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)}">`;
886
917
 
887
918
  this._rendering = true;
888
919
  this.innerHTML = `
@@ -890,8 +921,8 @@ export class DateCalendarElement extends HTMLElement {
890
921
  <div class="dp-calendar-header">
891
922
  ${yearFirst ? yearControl : monthControl}
892
923
  ${yearFirst ? monthControl : yearControl}
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>
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>
895
926
  </div>
896
927
  <h2 id="${headingId}" class="dp-calendar-heading dp-visually-hidden" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
897
928
  <table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
@@ -925,7 +956,7 @@ export class DateCalendarElement extends HTMLElement {
925
956
  const date = cell.dataset.date || "";
926
957
  const extra = cell.querySelector(".dp-day-extra");
927
958
  if (!(extra instanceof HTMLElement)) continue;
928
- const content = this._renderDay(date, this.getDateState(date));
959
+ const content = this._renderDay(date, this._renderedStates.get(date) || {});
929
960
  if (content instanceof Node) extra.append(content);
930
961
  else if (content !== undefined && content !== null) extra.textContent = String(content);
931
962
  }
package/src/date-field.js CHANGED
@@ -138,7 +138,7 @@ export class DateFieldController {
138
138
  input.setCustomValidity("");
139
139
  return;
140
140
  }
141
- input.setCustomValidity(text === canonical ? "" : this.messages.invalidDate);
141
+ input.setCustomValidity(this.messages.invalidDate);
142
142
  }
143
143
 
144
144
  /**
@@ -46,7 +46,7 @@ date-calendar {
46
46
 
47
47
  .dp-calendar-header {
48
48
  display: grid;
49
- grid-template-columns: minmax(0, 1fr) auto auto auto;
49
+ grid-template-columns: minmax(0, 1fr) var(--dp-year-inline-size) auto auto;
50
50
  /* Stretch (not center): once a UA natural height exceeds
51
51
  --dp-header-control-size, the row grows to the tallest control and the
52
52
  others stretch to match, so month/year/nav stay equal. */
@@ -62,12 +62,16 @@ date-calendar {
62
62
  track stays under the month select so the fixed-width year input never
63
63
  stretches. Visual mirroring itself is left to the inherited `dir`. */
64
64
  date-calendar[data-year-first] .dp-calendar-header {
65
- grid-template-columns: auto minmax(0, 1fr) auto auto;
65
+ grid-template-columns: var(--dp-year-inline-size) minmax(0, 1fr) auto auto;
66
66
  }
67
67
 
68
68
  .dp-calendar-header :where(button, select, input) {
69
- min-block-size: var(--dp-header-control-size);
70
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);
71
75
  /* Shared vertical metrics: UA default padding/line-height differ per control
72
76
  (up to ~13px apart), which only shows once --dp-header-control-size drops
73
77
  below content size. */
@@ -80,8 +84,7 @@ date-calendar[data-year-first] .dp-calendar-header {
80
84
  line-height: 1.2;
81
85
  }
82
86
 
83
- .dp-calendar-header input {
84
- min-inline-size: 0;
87
+ .dp-calendar-header input.dp-skin {
85
88
  padding-inline: 0.45rem;
86
89
  }
87
90
 
@@ -91,11 +94,13 @@ date-calendar[data-year-first] .dp-calendar-header {
91
94
  control and the others stretch to match. */
92
95
  .dp-month-select {
93
96
  inline-size: 100%;
94
- min-inline-size: 0;
95
97
  /* Long month names in a narrow track (compact consumers, long locales)
96
98
  would paint under the caret: truncate with an ellipsis instead. */
97
99
  overflow: hidden;
98
100
  text-overflow: ellipsis;
101
+ }
102
+
103
+ .dp-month-select.dp-skin {
99
104
  appearance: none;
100
105
  padding-inline: 0.45rem 2rem;
101
106
  background-image:
@@ -111,18 +116,18 @@ date-calendar[data-year-first] .dp-calendar-header {
111
116
  /* Inherited RTL mirrors the header layout via the grid. The logical
112
117
  `padding-inline` already flips, so only the physically-positioned caret has
113
118
  to follow it to the other side. */
114
- [dir="rtl"] .dp-month-select,
115
- html[dir="rtl"] .dp-month-select {
119
+ [dir="rtl"] .dp-month-select.dp-skin,
120
+ html[dir="rtl"] .dp-month-select.dp-skin {
116
121
  background-position:
117
122
  0.9rem 50%,
118
123
  0.65rem 50%;
119
124
  }
120
125
 
121
126
  .dp-year-input {
122
- inline-size: var(--dp-year-inline-size);
127
+ inline-size: 100%;
123
128
  }
124
129
 
125
- .dp-nav {
130
+ .dp-nav.dp-skin {
126
131
  display: grid;
127
132
  place-items: center;
128
133
  inline-size: var(--dp-header-control-size);
@@ -139,19 +144,19 @@ html[dir="rtl"] .dp-month-select {
139
144
  pointer-events: none;
140
145
  }
141
146
 
142
- .dp-calendar-header :where(button, select, input):disabled,
147
+ .dp-calendar-header .dp-skin:disabled,
143
148
  .dp-picker-button:disabled {
144
149
  cursor: not-allowed;
145
150
  opacity: 0.45;
146
151
  }
147
152
 
148
- .dp-nav:hover:not(:disabled) {
153
+ .dp-nav.dp-skin:hover:not(:disabled) {
149
154
  background: var(--dp-hover);
150
155
  }
151
156
 
152
157
  /* Inner outlines survive forced colors and keep focus inside each control.
153
158
  The picker trigger uses a tighter inset around its icon below. */
154
- .dp-calendar-header :where(button, select, input):focus-visible,
159
+ .dp-calendar-header .dp-skin:focus-visible,
155
160
  date-picker > input:not([type="hidden"]):focus-visible,
156
161
  .dp-day:focus-visible {
157
162
  outline: var(--dp-focus-width) solid var(--dp-focus);
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import { autoUpdate, reposition } from "@lekoala/floating";
2
+ import { getDefaultControlClasses, resolveControlClasses, sameControlClasses } from "./control-classes.js";
2
3
  import { compareDates, isDate, monthKey, todayISO } from "./date.js";
3
4
  import { DateCalendarElement } from "./date-calendar.js";
4
5
  import { DateFieldController } from "./date-field.js";
@@ -63,6 +64,7 @@ export class DatePickerElement extends HTMLElement {
63
64
  this._open = false;
64
65
  this._suppressFocusOpen = false;
65
66
  this._messages = getDefaultMessages();
67
+ this._controlClasses = getDefaultControlClasses();
66
68
  /** @type {any} */
67
69
  this._source = null;
68
70
  /** @type {any} */
@@ -353,6 +355,23 @@ export class DatePickerElement extends HTMLElement {
353
355
  return this._open;
354
356
  }
355
357
 
358
+ /**
359
+ * Author classes for header controls; merged with global defaults.
360
+ * @public
361
+ * @returns {import("./control-classes.js").ControlClasses}
362
+ */
363
+ get controlClasses() {
364
+ return { ...this._controlClasses };
365
+ }
366
+
367
+ /** @param {Partial<import("./control-classes.js").ControlClasses> | null} value */
368
+ set controlClasses(value) {
369
+ const classes = resolveControlClasses(value);
370
+ if (sameControlClasses(classes, this._controlClasses)) return;
371
+ this._controlClasses = classes;
372
+ if (this._calendar) this._calendar.controlClasses = this._controlClasses;
373
+ }
374
+
356
375
  /** @public */
357
376
  get messages() {
358
377
  return this._messages;
@@ -553,6 +572,31 @@ export class DatePickerElement extends HTMLElement {
553
572
  this._refreshButtonLabel();
554
573
  }
555
574
 
575
+ /** @param {AbortSignal} signal @param {HTMLInputElement | null | undefined} input @param {() => void} restore */
576
+ _bindPopup(signal, input, restore) {
577
+ this.ownerDocument.addEventListener(
578
+ "pointerdown",
579
+ (event) => {
580
+ if (!this._open || event.composedPath().includes(this)) return;
581
+ this.hide(false);
582
+ },
583
+ { capture: true, signal },
584
+ );
585
+ this.ownerDocument.addEventListener(
586
+ "reset",
587
+ (event) => {
588
+ if (!(event.target instanceof HTMLFormElement) || !input || event.target !== input.form) return;
589
+ // Reset is cancelable and runs before native values have been restored.
590
+ this.hide(false);
591
+ queueMicrotask(() => {
592
+ if (!event.defaultPrevented) restore();
593
+ });
594
+ },
595
+ { capture: true, signal },
596
+ );
597
+ this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
598
+ }
599
+
556
600
  _bind() {
557
601
  const input = this._input;
558
602
  const button = this._buttons[0]?.button;
@@ -641,30 +685,7 @@ export class DatePickerElement extends HTMLElement {
641
685
  // Re-check availability after a source load. validate() commits with
642
686
  // moveDisplay:false, so the visible month stays where the user navigated.
643
687
  calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
644
- this.ownerDocument.addEventListener(
645
- "pointerdown",
646
- (event) => {
647
- if (!this._open || event.composedPath().includes(this)) return;
648
- this.hide(false);
649
- },
650
- { capture: true, signal },
651
- );
652
- this.ownerDocument.addEventListener(
653
- "reset",
654
- (event) => {
655
- const form = event.target;
656
- if (form instanceof HTMLFormElement && this._input && form === this._input.form) {
657
- // The reset event fires before the control values are restored and
658
- // is cancelable; only resync when the reset actually applies.
659
- this.hide(false);
660
- queueMicrotask(() => {
661
- if (!event.defaultPrevented) this._restoreDefault();
662
- });
663
- }
664
- },
665
- { capture: true, signal },
666
- );
667
- this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
688
+ this._bindPopup(signal, this._input, () => this._restoreDefault());
668
689
  }
669
690
 
670
691
  _bindRange() {
@@ -772,28 +793,7 @@ export class DatePickerElement extends HTMLElement {
772
793
  // Re-check availability after a source load. validate() commits with
773
794
  // moveDisplay:false, so the visible month stays where the user navigated.
774
795
  calendar.addEventListener("dateloadend", () => void this.validate(), { signal });
775
- this.ownerDocument.addEventListener(
776
- "pointerdown",
777
- (event) => {
778
- if (!this._open || event.composedPath().includes(this)) return;
779
- this.hide(false);
780
- },
781
- { capture: true, signal },
782
- );
783
- this.ownerDocument.addEventListener(
784
- "reset",
785
- (event) => {
786
- const form = event.target;
787
- if (form instanceof HTMLFormElement && this._fields && form === this._fields[0].input.form) {
788
- this.hide(false);
789
- queueMicrotask(() => {
790
- if (!event.defaultPrevented) this._restoreRangeDefault();
791
- });
792
- }
793
- },
794
- { capture: true, signal },
795
- );
796
- this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
796
+ this._bindPopup(signal, this._fields?.[0]?.input, () => this._restoreRangeDefault());
797
797
  }
798
798
 
799
799
  /**
@@ -1177,6 +1177,7 @@ export class DatePickerElement extends HTMLElement {
1177
1177
  calendar.min = this.min;
1178
1178
  calendar.max = this.max;
1179
1179
  calendar.monthFormat = this.monthFormat;
1180
+ calendar.controlClasses = this._controlClasses;
1180
1181
  calendar.messages = this._messages;
1181
1182
  calendar.source = this._source;
1182
1183
  calendar.dateState = this._dateState;
package/src/date.js CHANGED
@@ -165,9 +165,10 @@ export function endOfWeek(value, firstDay = 1) {
165
165
  }
166
166
 
167
167
  /**
168
- * True civil weeks covering the anchor month: 4..6 full rows, never padded.
168
+ * Civil weeks covering the anchor month: 4..6 full rows. Optional fixed weeks
169
+ * pads to six rows when a full extra week fits inside the canonical window.
169
170
  * @param {string} value YYYY-MM or YYYY-MM-DD
170
- * @param {{firstDay?: number}} [options]
171
+ * @param {{firstDay?: number, fixedWeeks?: boolean}} [options]
171
172
  */
172
173
  export function getMonthWeeks(value, options = {}) {
173
174
  const firstDay = options.firstDay ?? 1;
@@ -183,7 +184,11 @@ export function getMonthWeeks(value, options = {}) {
183
184
  // Stop from the built week's own end: advancing the cursor past the final
184
185
  // week (e.g. from 9999-12-25 to 10000-01-01) would leave the canonical
185
186
  // window even when every emitted day is valid.
186
- if (compareDates(week[6], end) >= 0) break;
187
+ if (compareDates(week[6], end) >= 0) {
188
+ // Another complete week must fit before the last canonical date.
189
+ if (!options.fixedWeeks || weeks.length >= 6 || compareDates(week[6], addDays("9999-12-31", -7)) > 0)
190
+ break;
191
+ }
187
192
  cursor = addDays(cursor, 7);
188
193
  }
189
194
  return weeks;
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { CalendarModel } from "./calendar-model.js";
2
+ export { getDefaultControlClasses, setDefaultControlClasses } from "./control-classes.js";
2
3
  export * as dates from "./date.js";
3
4
  export { DateCalendarElement } from "./date-calendar.js";
4
5
  export { DatePickerElement } from "./date-picker.js";
package/src/intl.js CHANGED
@@ -2,6 +2,33 @@ import { isDate, normalizeFirstDay, parseDate, toIntlDate, toISODate } from "./d
2
2
 
3
3
  const BIDI = /[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g;
4
4
 
5
+ /** @type {Map<string, Intl.DateTimeFormat>} */
6
+ const dateFormatters = new Map();
7
+ /** @type {Map<string, Map<string, string>>} */
8
+ const digitMaps = new Map();
9
+
10
+ /**
11
+ * Bound retained locales/options even when an application cycles through many.
12
+ * @template T
13
+ * @param {Map<string, T>} cache @param {string} key @param {() => T} create
14
+ * @returns {T}
15
+ */
16
+ function cached(cache, key, create) {
17
+ const existing = cache.get(key);
18
+ if (existing !== undefined) return existing;
19
+ const value = create();
20
+ const oldest = cache.keys().next().value;
21
+ if (cache.size >= 64 && oldest !== undefined) cache.delete(oldest);
22
+ cache.set(key, value);
23
+ return value;
24
+ }
25
+
26
+ /** @param {string} locale @param {Intl.DateTimeFormatOptions} options */
27
+ function dateFormatter(locale, options) {
28
+ const key = JSON.stringify([locale, options]);
29
+ return cached(dateFormatters, key, () => new Intl.DateTimeFormat(locale, options));
30
+ }
31
+
5
32
  /** @param {string} [locale] */
6
33
  export function resolveLocale(locale = "") {
7
34
  if (locale) return locale;
@@ -12,10 +39,12 @@ export function resolveLocale(locale = "") {
12
39
 
13
40
  /** @param {string} locale */
14
41
  function digitMap(locale) {
15
- const formatter = new Intl.NumberFormat(locale, { useGrouping: false });
16
- const map = new Map();
17
- for (let i = 0; i <= 9; i++) map.set(formatter.format(i), String(i));
18
- return map;
42
+ return cached(digitMaps, locale, () => {
43
+ const formatter = new Intl.NumberFormat(locale, { useGrouping: false });
44
+ const map = new Map();
45
+ for (let i = 0; i <= 9; i++) map.set(formatter.format(i), String(i));
46
+ return map;
47
+ });
19
48
  }
20
49
 
21
50
  /**
@@ -33,7 +62,7 @@ function normalizeDigits(text, locale, options = {}) {
33
62
  /** @param {string} value @param {string} [locale] */
34
63
  export function formatLongDate(value, locale = "") {
35
64
  const resolved = resolveLocale(locale);
36
- return new Intl.DateTimeFormat(resolved, {
65
+ return dateFormatter(resolved, {
37
66
  calendar: "gregory",
38
67
  weekday: "long",
39
68
  year: "numeric",
@@ -46,7 +75,7 @@ export function formatLongDate(value, locale = "") {
46
75
  /** @param {string} value @param {string} [locale] */
47
76
  export function formatMonthYear(value, locale = "") {
48
77
  const resolved = resolveLocale(locale);
49
- return new Intl.DateTimeFormat(resolved, {
78
+ return dateFormatter(resolved, {
50
79
  calendar: "gregory",
51
80
  year: "numeric",
52
81
  month: "long",
@@ -65,7 +94,7 @@ export function formatMonthYear(value, locale = "") {
65
94
  export function monthYearOrder(locale = "") {
66
95
  const resolved = resolveLocale(locale);
67
96
  try {
68
- const parts = new Intl.DateTimeFormat(resolved, {
97
+ const parts = dateFormatter(resolved, {
69
98
  calendar: "gregory",
70
99
  year: "numeric",
71
100
  month: "long",
@@ -92,7 +121,7 @@ export function monthYearOrder(locale = "") {
92
121
  /** @param {string} [locale] @param {"long"|"short"} [style] */
93
122
  export function monthNames(locale = "", style = "long") {
94
123
  const resolved = resolveLocale(locale);
95
- const formatter = new Intl.DateTimeFormat(resolved, { calendar: "gregory", month: style, timeZone: "UTC" });
124
+ const formatter = dateFormatter(resolved, { calendar: "gregory", month: style, timeZone: "UTC" });
96
125
  return Array.from({ length: 12 }, (_, index) =>
97
126
  formatter.format(toIntlDate(`2026-${String(index + 1).padStart(2, "0")}-15`)),
98
127
  );
@@ -105,7 +134,7 @@ export function monthNames(locale = "", style = "long") {
105
134
  */
106
135
  export function weekdayNames(locale = "", firstDay = 1, style = "short") {
107
136
  const resolved = resolveLocale(locale);
108
- const formatter = new Intl.DateTimeFormat(resolved, {
137
+ const formatter = dateFormatter(resolved, {
109
138
  calendar: "gregory",
110
139
  weekday: style,
111
140
  timeZone: "UTC",
@@ -131,7 +160,7 @@ function escapeRegExp(value) {
131
160
  */
132
161
  export function createDateAdapter(locale = "") {
133
162
  const resolved = resolveLocale(locale);
134
- const formatter = new Intl.DateTimeFormat(resolved, {
163
+ const formatter = dateFormatter(resolved, {
135
164
  calendar: "gregory",
136
165
  year: "numeric",
137
166
  month: "2-digit",
package/src/source.js CHANGED
@@ -28,17 +28,27 @@ export function normalizeDateStates(payload) {
28
28
 
29
29
  /**
30
30
  * Small fetch adapter for legacy/configUrl-like endpoints.
31
+ * Resolve and validate the URL immediately against the document or worker location.
31
32
  * @param {string | URL} url
32
33
  * @param {{map?:(payload:unknown)=>unknown, params?:(range:{start:string,end:string})=>Record<string,string>}} [options]
33
34
  */
34
35
  export function createFetchSource(url, options = {}) {
36
+ const base = typeof document !== "undefined" ? document.baseURI : globalThis.location?.href;
37
+ let resolved;
38
+ try {
39
+ resolved = new URL(String(url), base);
40
+ } catch (cause) {
41
+ throw new TypeError(
42
+ base
43
+ ? `Invalid date source URL: ${url}`
44
+ : "Date source: absolute URL required outside a document or worker location",
45
+ { cause },
46
+ );
47
+ }
35
48
  return {
36
49
  /** @param {{start:string,end:string}} range @param {{signal?:AbortSignal}} [context] */
37
50
  async load(range, context = {}) {
38
- const target = new URL(
39
- String(url),
40
- typeof document !== "undefined" ? document.baseURI : "http://localhost/",
41
- );
51
+ const target = new URL(resolved);
42
52
  const params = options.params ? options.params(range) : range;
43
53
  for (const [key, value] of Object.entries(params)) target.searchParams.set(key, String(value));
44
54
  const response = await fetch(target, { signal: context.signal });