@lekoala/date-picker 0.4.0 → 0.5.1

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 (39) hide show
  1. package/README.md +294 -265
  2. package/custom-elements.json +50 -0
  3. package/dist/date-picker.css +20 -15
  4. package/dist/date-picker.js +366 -149
  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 +34 -29
  8. package/dist/types/control-classes.d.ts +40 -0
  9. package/dist/types/control-classes.d.ts.map +1 -0
  10. package/dist/types/date-calendar.d.ts +35 -43
  11. package/dist/types/date-calendar.d.ts.map +1 -1
  12. package/dist/types/date-picker.d.ts +44 -53
  13. package/dist/types/date-picker.d.ts.map +1 -1
  14. package/dist/types/date.d.ts +4 -2
  15. package/dist/types/date.d.ts.map +1 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/index.d.ts.map +1 -1
  18. package/dist/types/intl.d.ts.map +1 -1
  19. package/dist/types/messages.d.ts +2 -0
  20. package/dist/types/messages.d.ts.map +1 -1
  21. package/dist/types/source.d.ts +1 -0
  22. package/dist/types/source.d.ts.map +1 -1
  23. package/dist/types/upgrade-properties.d.ts +8 -0
  24. package/dist/types/upgrade-properties.d.ts.map +1 -0
  25. package/docs/API.md +442 -408
  26. package/docs/DECISIONS.md +74 -62
  27. package/docs/USE_CASES.md +156 -144
  28. package/package.json +3 -3
  29. package/src/control-classes.js +80 -0
  30. package/src/date-calendar.js +124 -27
  31. package/src/date-field.js +1 -1
  32. package/src/date-picker.css +20 -15
  33. package/src/date-picker.js +133 -74
  34. package/src/date.js +8 -3
  35. package/src/index.js +1 -0
  36. package/src/intl.js +39 -10
  37. package/src/messages.js +12 -0
  38. package/src/source.js +14 -4
  39. package/src/upgrade-properties.js +14 -0
@@ -1,11 +1,18 @@
1
1
  import { autoUpdate, reposition } from "@lekoala/floating";
2
+ import {
3
+ getDefaultControlClasses,
4
+ normalizeControlClasses,
5
+ resolveControlClasses,
6
+ setDefaultControlClasses,
7
+ } from "./control-classes.js";
2
8
  import { compareDates, isDate, monthKey, todayISO } from "./date.js";
3
9
  import { DateCalendarElement } from "./date-calendar.js";
4
10
  import { DateFieldController } from "./date-field.js";
5
11
  import { DateRangeController, normalizeRange } from "./date-range.js";
6
12
  import { createDateAdapter, formatLongDate, resolveLocale } from "./intl.js";
7
- import { getDefaultMessages } from "./messages.js";
13
+ import { getDefaultMessages, setDefaultMessages, subscribeDefaultMessages } from "./messages.js";
8
14
  import { compareTimes, isTime } from "./time.js";
15
+ import { upgradeProperties } from "./upgrade-properties.js";
9
16
 
10
17
  let uid = 0;
11
18
 
@@ -15,6 +22,26 @@ const DRAG_THRESHOLD = 4;
15
22
  export class DatePickerElement extends HTMLElement {
16
23
  static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
17
24
 
25
+ /** @public @param {Partial<import("./control-classes.js").ControlClasses>} classes */
26
+ static setDefaultControlClasses(classes) {
27
+ setDefaultControlClasses(classes);
28
+ }
29
+
30
+ /** @public */
31
+ static getDefaultControlClasses() {
32
+ return getDefaultControlClasses();
33
+ }
34
+
35
+ /** @public @param {Partial<typeof import("./messages.js").DEFAULT_MESSAGES>} messages */
36
+ static setDefaultMessages(messages) {
37
+ setDefaultMessages(messages);
38
+ }
39
+
40
+ /** @public */
41
+ static getDefaultMessages() {
42
+ return getDefaultMessages();
43
+ }
44
+
18
45
  constructor() {
19
46
  super();
20
47
  this._id = `date-picker-${++uid}`;
@@ -62,7 +89,12 @@ export class DatePickerElement extends HTMLElement {
62
89
  this._stopTracking = null;
63
90
  this._open = false;
64
91
  this._suppressFocusOpen = false;
65
- this._messages = getDefaultMessages();
92
+ /** @type {typeof import("./messages.js").DEFAULT_MESSAGES | null} */
93
+ this._messages = null;
94
+ /** @type {Partial<import("./control-classes.js").ControlClasses> | null} */
95
+ this._controlClasses = null;
96
+ /** @type {(() => void) | null} */
97
+ this._stopDefaultMessages = null;
66
98
  /** @type {any} */
67
99
  this._source = null;
68
100
  /** @type {any} */
@@ -81,11 +113,29 @@ export class DatePickerElement extends HTMLElement {
81
113
 
82
114
  connectedCallback() {
83
115
  if (this._connected) return;
84
- if (this.hasAttribute("range")) {
85
- this._connectRange();
86
- return;
116
+ upgradeProperties(this, [
117
+ "locale",
118
+ "min",
119
+ "max",
120
+ "monthFormat",
121
+ "openOnFocus",
122
+ "coordinateSpace",
123
+ "controlClasses",
124
+ "messages",
125
+ "source",
126
+ "dateState",
127
+ "renderDay",
128
+ "isDateDisabled",
129
+ ]);
130
+ if (this.hasAttribute("range")) this._connectRange();
131
+ else this._connectSingle();
132
+ // The range setter requires the controller created by _connectRange().
133
+ if (this._connected) {
134
+ upgradeProperties(this, ["value", "range"]);
135
+ this._stopDefaultMessages = subscribeDefaultMessages(() => {
136
+ if (this._messages === null) this._refreshMessages();
137
+ });
87
138
  }
88
- this._connectSingle();
89
139
  }
90
140
 
91
141
  _connectSingle() {
@@ -99,7 +149,7 @@ export class DatePickerElement extends HTMLElement {
99
149
  this._connected = true;
100
150
  this._input = input;
101
151
  this._discoverTimeFields();
102
- this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
152
+ this._field = new DateFieldController(input, { messages: this.messages, locale: this.locale });
103
153
  this._field.onAttributesChanged = () => this._syncInputState();
104
154
  this._field.confirm = (date, options) => this._confirmDate(date, options);
105
155
  this._setupFormValue();
@@ -113,7 +163,7 @@ export class DatePickerElement extends HTMLElement {
113
163
  this.getAttribute("value") || (isDate(input.value) ? input.value : adapter.parse(input.value));
114
164
  if (initial && isDate(initial)) this._setValue(initial, { emit: false, format: true });
115
165
  else if (!input.value) this._setValue("", { emit: false, format: false });
116
- else input.setCustomValidity(this._messages.invalidDate);
166
+ else input.setCustomValidity(this.messages.invalidDate);
117
167
 
118
168
  // Native form reset restores control defaultValues, then _restoreDefault()
119
169
  // recommits the canonical value from them. Never redefine an author-provided
@@ -169,8 +219,8 @@ export class DatePickerElement extends HTMLElement {
169
219
  this._connected = true;
170
220
  this._input = null;
171
221
  this._fields = [
172
- new DateFieldController(startInput, { messages: this._messages, locale: this.locale }),
173
- new DateFieldController(endInput, { messages: this._messages, locale: this.locale }),
222
+ new DateFieldController(startInput, { messages: this.messages, locale: this.locale }),
223
+ new DateFieldController(endInput, { messages: this.messages, locale: this.locale }),
174
224
  ];
175
225
  this._range = new DateRangeController();
176
226
  this._rangeOriginalDescribedBy = [
@@ -193,8 +243,8 @@ export class DatePickerElement extends HTMLElement {
193
243
  const initialEnd = isDate(endInput.value) ? endInput.value : adapter.parse(endInput.value);
194
244
  this._setRange(initialStart || "", initialEnd || "", { emit: false });
195
245
  if (startInput.value.trim() && !isDate(initialStart))
196
- startInput.setCustomValidity(this._messages.invalidDate);
197
- if (endInput.value.trim() && !isDate(initialEnd)) endInput.setCustomValidity(this._messages.invalidDate);
246
+ startInput.setCustomValidity(this.messages.invalidDate);
247
+ if (endInput.value.trim() && !isDate(initialEnd)) endInput.setCustomValidity(this.messages.invalidDate);
198
248
 
199
249
  if (this._fields[0].hidden) this._fields[0].hidden.defaultValue = this._range.start;
200
250
  if (this._fields[1].hidden) this._fields[1].hidden.defaultValue = this._range.end;
@@ -207,6 +257,8 @@ export class DatePickerElement extends HTMLElement {
207
257
  }
208
258
 
209
259
  disconnectedCallback() {
260
+ this._stopDefaultMessages?.();
261
+ this._stopDefaultMessages = null;
210
262
  this._connected = false;
211
263
  this.hide(false);
212
264
  this._controller?.abort();
@@ -353,18 +405,43 @@ export class DatePickerElement extends HTMLElement {
353
405
  return this._open;
354
406
  }
355
407
 
356
- /** @public */
408
+ /**
409
+ * Author classes for header controls; omitted roles follow current global defaults.
410
+ * @public
411
+ * @returns {import("./control-classes.js").ControlClasses}
412
+ */
413
+ get controlClasses() {
414
+ return resolveControlClasses(this._controlClasses);
415
+ }
416
+
417
+ /** @param {Partial<import("./control-classes.js").ControlClasses> | null} value */
418
+ set controlClasses(value) {
419
+ this._controlClasses = normalizeControlClasses(value);
420
+ if (this._calendar) this._calendar.controlClasses = this._controlClasses;
421
+ }
422
+
423
+ /** @public @returns {typeof import("./messages.js").DEFAULT_MESSAGES} */
357
424
  get messages() {
358
- return this._messages;
425
+ return this._messages ?? getDefaultMessages();
359
426
  }
360
427
 
428
+ /** @param {Partial<typeof import("./messages.js").DEFAULT_MESSAGES> | null} value */
361
429
  set messages(value) {
362
- this._messages = { ...getDefaultMessages(), ...(value || {}) };
430
+ this._messages = value == null ? null : { ...getDefaultMessages(), ...value };
363
431
  if (this._calendar) this._calendar.messages = this._messages;
364
- for (const field of this._fields || []) field.setMessages(this._messages);
365
- this._field?.setMessages(this._messages);
432
+ this._refreshMessages();
433
+ }
434
+
435
+ _refreshMessages() {
436
+ const messages = this.messages;
437
+ for (const field of this._fields || []) field.setMessages(messages);
438
+ this._field?.setMessages(messages);
439
+ this._panel?.setAttribute("aria-label", messages.calendar);
440
+ const field = this._field ?? this._fields?.[0];
441
+ if (this._formatHint && field)
442
+ this._formatHint.textContent = `${messages.formatHint}: ${field.placeholder}`;
366
443
  this._refreshButtonLabel();
367
- void this.validate();
444
+ if (this._connected) void this.validate();
368
445
  }
369
446
 
370
447
  /** @public @returns {any} */
@@ -522,7 +599,7 @@ export class DatePickerElement extends HTMLElement {
522
599
  panel.className = "dp-picker-panel";
523
600
  panel.setAttribute("popover", "manual");
524
601
  panel.setAttribute("role", "dialog");
525
- panel.setAttribute("aria-label", this._messages.calendar);
602
+ panel.setAttribute("aria-label", this.messages.calendar);
526
603
 
527
604
  const calendar = new DateCalendarElement();
528
605
  calendar.selection = "none";
@@ -553,6 +630,31 @@ export class DatePickerElement extends HTMLElement {
553
630
  this._refreshButtonLabel();
554
631
  }
555
632
 
633
+ /** @param {AbortSignal} signal @param {HTMLInputElement | null | undefined} input @param {() => void} restore */
634
+ _bindPopup(signal, input, restore) {
635
+ this.ownerDocument.addEventListener(
636
+ "pointerdown",
637
+ (event) => {
638
+ if (!this._open || event.composedPath().includes(this)) return;
639
+ this.hide(false);
640
+ },
641
+ { capture: true, signal },
642
+ );
643
+ this.ownerDocument.addEventListener(
644
+ "reset",
645
+ (event) => {
646
+ if (!(event.target instanceof HTMLFormElement) || !input || event.target !== input.form) return;
647
+ // Reset is cancelable and runs before native values have been restored.
648
+ this.hide(false);
649
+ queueMicrotask(() => {
650
+ if (!event.defaultPrevented) restore();
651
+ });
652
+ },
653
+ { capture: true, signal },
654
+ );
655
+ this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
656
+ }
657
+
556
658
  _bind() {
557
659
  const input = this._input;
558
660
  const button = this._buttons[0]?.button;
@@ -641,30 +743,7 @@ export class DatePickerElement extends HTMLElement {
641
743
  // Re-check availability after a source load. validate() commits with
642
744
  // moveDisplay:false, so the visible month stays where the user navigated.
643
745
  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 });
746
+ this._bindPopup(signal, this._input, () => this._restoreDefault());
668
747
  }
669
748
 
670
749
  _bindRange() {
@@ -772,28 +851,7 @@ export class DatePickerElement extends HTMLElement {
772
851
  // Re-check availability after a source load. validate() commits with
773
852
  // moveDisplay:false, so the visible month stays where the user navigated.
774
853
  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 });
854
+ this._bindPopup(signal, this._fields?.[0]?.input, () => this._restoreRangeDefault());
797
855
  }
798
856
 
799
857
  /**
@@ -1177,13 +1235,14 @@ export class DatePickerElement extends HTMLElement {
1177
1235
  calendar.min = this.min;
1178
1236
  calendar.max = this.max;
1179
1237
  calendar.monthFormat = this.monthFormat;
1238
+ calendar.controlClasses = this._controlClasses;
1180
1239
  calendar.messages = this._messages;
1181
1240
  calendar.source = this._source;
1182
1241
  calendar.dateState = this._dateState;
1183
1242
  calendar.renderDay = this._renderDay;
1184
1243
  calendar.isDateDisabled = this._isDateDisabled;
1185
1244
  if (!this._rangeMode() && this._value) calendar.value = this._value;
1186
- if (panel) panel.setAttribute("aria-label", this._messages.calendar);
1245
+ if (panel) panel.setAttribute("aria-label", this.messages.calendar);
1187
1246
  }
1188
1247
 
1189
1248
  _refreshLocale() {
@@ -1192,7 +1251,7 @@ export class DatePickerElement extends HTMLElement {
1192
1251
  if (this._rangeMode() && this._fields) {
1193
1252
  this._fields[0].setLocale(this.locale);
1194
1253
  this._fields[1].setLocale(this.locale);
1195
- hint.textContent = `${this._messages.formatHint}: ${this._fields[0].placeholder}`;
1254
+ hint.textContent = `${this.messages.formatHint}: ${this._fields[0].placeholder}`;
1196
1255
  for (const [index, field] of this._fields.entries()) {
1197
1256
  if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
1198
1257
  field.input.placeholder = field.placeholder;
@@ -1207,7 +1266,7 @@ export class DatePickerElement extends HTMLElement {
1207
1266
  const field = this._field;
1208
1267
  if (!field) return;
1209
1268
  field.setLocale(this.locale);
1210
- hint.textContent = `${this._messages.formatHint}: ${field.placeholder}`;
1269
+ hint.textContent = `${this.messages.formatHint}: ${field.placeholder}`;
1211
1270
  if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
1212
1271
  field.input.placeholder = field.placeholder;
1213
1272
  this._generatedPlaceholder = true;
@@ -1223,7 +1282,7 @@ export class DatePickerElement extends HTMLElement {
1223
1282
  this._refreshRangeButtonLabel();
1224
1283
  return;
1225
1284
  }
1226
- const prefix = this._value ? this._messages.changeDate : this._messages.chooseDate;
1285
+ const prefix = this._value ? this.messages.changeDate : this.messages.chooseDate;
1227
1286
  const suffix = this._value ? `, ${formatLongDate(this._value, this.locale)}` : "";
1228
1287
  single.setAttribute("aria-label", `${prefix}${suffix}`);
1229
1288
  }
@@ -1235,9 +1294,9 @@ export class DatePickerElement extends HTMLElement {
1235
1294
  const { start, end } = this._range;
1236
1295
  for (const { button, endpoint } of this._buttons) {
1237
1296
  const bound = endpoint === "end" ? end : start;
1238
- const prefix = bound ? this._messages.changeDate : this._messages.chooseDate;
1297
+ const prefix = bound ? this.messages.changeDate : this.messages.chooseDate;
1239
1298
  const suffix = bound ? `, ${formatLongDate(bound, this.locale)}` : "";
1240
- const boundLabel = endpoint === "end" ? this._messages.rangeEnd : this._messages.rangeStart;
1299
+ const boundLabel = endpoint === "end" ? this.messages.rangeEnd : this.messages.rangeStart;
1241
1300
  button.setAttribute("aria-label", `${prefix}${suffix}, ${boundLabel}`);
1242
1301
  }
1243
1302
  }
@@ -1293,7 +1352,7 @@ export class DatePickerElement extends HTMLElement {
1293
1352
  }
1294
1353
  const confirmed = await calendar.ensureDate(date);
1295
1354
  const state = calendar.getDateState(date);
1296
- if (!confirmed || state.disabled) return { ok: false, message: this._messages.unavailableDate };
1355
+ if (!confirmed || state.disabled) return { ok: false, message: this.messages.unavailableDate };
1297
1356
  return { ok: true };
1298
1357
  }
1299
1358
 
@@ -1424,7 +1483,7 @@ export class DatePickerElement extends HTMLElement {
1424
1483
  for (const bound of bounds) {
1425
1484
  const index = bound === "end" ? 1 : 0;
1426
1485
  const input = this._fields[index].input;
1427
- const message = bound === "end" ? this._messages.rangeOrderEnd : this._messages.rangeOrderStart;
1486
+ const message = bound === "end" ? this.messages.rangeOrderEnd : this.messages.rangeOrderStart;
1428
1487
  const shouldHaveOrder = inverted && bound === which;
1429
1488
  if (shouldHaveOrder) {
1430
1489
  input.setCustomValidity(message);
@@ -1460,7 +1519,7 @@ export class DatePickerElement extends HTMLElement {
1460
1519
  for (const [index, bound] of bounds.entries()) {
1461
1520
  const input = inputs[index];
1462
1521
  if (!input) continue;
1463
- const message = bound === "end" ? this._messages.rangeOrderEnd : this._messages.rangeOrderStart;
1522
+ const message = bound === "end" ? this.messages.rangeOrderEnd : this.messages.rangeOrderStart;
1464
1523
  const shouldHaveOrder = inverted && bound === which;
1465
1524
  if (shouldHaveOrder) {
1466
1525
  input.setCustomValidity(message);
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/messages.js CHANGED
@@ -28,4 +28,16 @@ export function getDefaultMessages() {
28
28
  /** @param {Partial<typeof DEFAULT_MESSAGES>} messages */
29
29
  export function setDefaultMessages(messages) {
30
30
  defaults = { ...defaults, ...messages };
31
+ for (const listener of listeners) listener();
32
+ }
33
+
34
+ /** @type {Set<() => void>} */
35
+ const listeners = new Set();
36
+
37
+ /** @param {() => void} listener */
38
+ export function subscribeDefaultMessages(listener) {
39
+ listeners.add(listener);
40
+ return () => {
41
+ listeners.delete(listener);
42
+ };
31
43
  }
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 });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Replay properties assigned before custom-element definition through their setters.
3
+ * Call only once the element is ready to accept the listed properties.
4
+ * @param {HTMLElement} element
5
+ * @param {string[]} properties
6
+ */
7
+ export function upgradeProperties(element, properties) {
8
+ for (const property of properties) {
9
+ if (!Object.hasOwn(element, property)) continue;
10
+ const value = Reflect.get(element, property);
11
+ Reflect.deleteProperty(element, property);
12
+ Reflect.set(element, property, value);
13
+ }
14
+ }