@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.
- package/README.md +294 -265
- package/custom-elements.json +50 -0
- package/dist/date-picker.css +20 -15
- package/dist/date-picker.js +366 -149
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +34 -29
- package/dist/types/control-classes.d.ts +40 -0
- package/dist/types/control-classes.d.ts.map +1 -0
- package/dist/types/date-calendar.d.ts +35 -43
- package/dist/types/date-calendar.d.ts.map +1 -1
- package/dist/types/date-picker.d.ts +44 -53
- package/dist/types/date-picker.d.ts.map +1 -1
- package/dist/types/date.d.ts +4 -2
- package/dist/types/date.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/intl.d.ts.map +1 -1
- package/dist/types/messages.d.ts +2 -0
- package/dist/types/messages.d.ts.map +1 -1
- package/dist/types/source.d.ts +1 -0
- package/dist/types/source.d.ts.map +1 -1
- package/dist/types/upgrade-properties.d.ts +8 -0
- package/dist/types/upgrade-properties.d.ts.map +1 -0
- package/docs/API.md +442 -408
- package/docs/DECISIONS.md +74 -62
- package/docs/USE_CASES.md +156 -144
- package/package.json +3 -3
- package/src/control-classes.js +80 -0
- package/src/date-calendar.js +124 -27
- package/src/date-field.js +1 -1
- package/src/date-picker.css +20 -15
- package/src/date-picker.js +133 -74
- package/src/date.js +8 -3
- package/src/index.js +1 -0
- package/src/intl.js +39 -10
- package/src/messages.js +12 -0
- package/src/source.js +14 -4
- package/src/upgrade-properties.js +14 -0
package/src/date-picker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
173
|
-
new DateFieldController(endInput, { messages: this.
|
|
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.
|
|
197
|
-
if (endInput.value.trim() && !isDate(initialEnd)) endInput.setCustomValidity(this.
|
|
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
|
-
/**
|
|
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(), ...
|
|
430
|
+
this._messages = value == null ? null : { ...getDefaultMessages(), ...value };
|
|
363
431
|
if (this._calendar) this._calendar.messages = this._messages;
|
|
364
|
-
|
|
365
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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)
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|