@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.
- package/README.md +281 -263
- package/custom-elements.json +30 -0
- package/dist/date-picker.css +51 -46
- package/dist/date-picker.js +685 -204
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +65 -60
- package/dist/types/control-classes.d.ts +30 -0
- package/dist/types/control-classes.d.ts.map +1 -0
- package/dist/types/date-calendar.d.ts +49 -3
- package/dist/types/date-calendar.d.ts.map +1 -1
- package/dist/types/date-field.d.ts +12 -4
- package/dist/types/date-field.d.ts.map +1 -1
- package/dist/types/date-picker.d.ts +106 -9
- package/dist/types/date-picker.d.ts.map +1 -1
- package/dist/types/date-range.d.ts +102 -20
- package/dist/types/date-range.d.ts.map +1 -1
- package/dist/types/date.d.ts +14 -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/source.d.ts +1 -0
- package/dist/types/source.d.ts.map +1 -1
- package/docs/ACCESSIBILITY.md +6 -2
- package/docs/API.md +430 -358
- package/docs/DECISIONS.md +68 -49
- package/docs/MIGRATION-FLATPICKR.md +1 -1
- package/docs/TESTING.md +1 -1
- package/docs/USE_CASES.md +156 -141
- package/package.json +8 -8
- package/src/control-classes.js +50 -0
- package/src/date-calendar.js +188 -54
- package/src/date-field.js +9 -5
- package/src/date-picker.css +51 -46
- package/src/date-picker.js +392 -83
- package/src/date-range.js +184 -30
- package/src/date.js +57 -3
- package/src/index.js +1 -0
- package/src/intl.js +52 -17
- package/src/source.js +14 -4
package/src/date-range.js
CHANGED
|
@@ -45,21 +45,41 @@ export function rangePosition(date, range) {
|
|
|
45
45
|
return "";
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} RangeTransition
|
|
50
|
+
* @property {"pending" | "complete" | "refused"} status
|
|
51
|
+
* @property {"start" | "end"} endpoint primary bound the transition speaks through
|
|
52
|
+
* @property {("start" | "end")[]} changedEndpoints bounds whose value actually moves
|
|
53
|
+
* @property {DateRange} range the whole resulting pair, not only the moved bound
|
|
54
|
+
* @property {"" | "start" | "end"} activeEndpoint bound the next pick would target
|
|
55
|
+
* @property {boolean} [close] the workflow cannot continue in the popup
|
|
56
|
+
*/
|
|
57
|
+
|
|
48
58
|
/**
|
|
49
59
|
* Pure two-bound state machine for the shared-calendar range picker. Covers
|
|
50
60
|
* start/end state and the active-endpoint transition rules; it knows nothing
|
|
51
61
|
* about DOM, popups or async availability.
|
|
52
62
|
*
|
|
53
|
-
* Interaction contract
|
|
63
|
+
* Interaction contract:
|
|
54
64
|
* - opening through a field targets that bound;
|
|
55
65
|
* - a pick outside a complete range extends the corresponding bound;
|
|
56
|
-
* -
|
|
66
|
+
* - while a range is being created (`start` set, no `end`), a second pick
|
|
67
|
+
* before the anchor is sorted into `date -> start` instead of refused:
|
|
68
|
+
* calendar interaction never produces an inverted range;
|
|
69
|
+
* - an explicit change to one bound of an already complete pair keeps that
|
|
70
|
+
* bound's identity, so moving `end` before `start` is refused rather than
|
|
71
|
+
* silently rewriting the other field;
|
|
57
72
|
* - activating through `start` commits `start`, moves the active endpoint to
|
|
58
73
|
* `end` and leaves the popup open for the second selection.
|
|
59
74
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
75
|
+
* Each rule exists once, as a `project*()` projection; `activate()` and
|
|
76
|
+
* `moveEndpoint()` only apply one. Hover, keyboard and drag previews read the
|
|
77
|
+
* same projections, so a second copy of the interaction rules cannot drift
|
|
78
|
+
* into the DOM layer.
|
|
79
|
+
*
|
|
80
|
+
* Text entry is deliberately outside this machine: a typed pair may be
|
|
81
|
+
* temporarily inverted and report a validation error. It is the coordinator's
|
|
82
|
+
* job to forward only a displayable range to `calendar.highlightedRange`.
|
|
63
83
|
*/
|
|
64
84
|
export class DateRangeController {
|
|
65
85
|
constructor() {
|
|
@@ -69,7 +89,7 @@ export class DateRangeController {
|
|
|
69
89
|
this.end = "";
|
|
70
90
|
}
|
|
71
91
|
|
|
72
|
-
/** @returns {
|
|
92
|
+
/** @returns {DateRange} */
|
|
73
93
|
get range() {
|
|
74
94
|
return { start: this.start, end: this.end };
|
|
75
95
|
}
|
|
@@ -84,44 +104,178 @@ export class DateRangeController {
|
|
|
84
104
|
this.activeEndpoint = endpoint;
|
|
85
105
|
}
|
|
86
106
|
|
|
107
|
+
/** @param {"start" | "end"} endpoint @returns {RangeTransition} */
|
|
108
|
+
_refuse(endpoint) {
|
|
109
|
+
return {
|
|
110
|
+
status: "refused",
|
|
111
|
+
endpoint,
|
|
112
|
+
changedEndpoints: [],
|
|
113
|
+
range: this.range,
|
|
114
|
+
activeEndpoint: this.activeEndpoint,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
87
118
|
/**
|
|
88
|
-
*
|
|
119
|
+
* Project one grid activation through the active endpoint, without mutating.
|
|
89
120
|
*
|
|
90
121
|
* `editable(bound)` reports whether a bound can receive a user pick. It
|
|
91
122
|
* guards every commit and the `start -> end` transition: when the next bound
|
|
92
|
-
* is uneditable the returned
|
|
93
|
-
* the popup targeting a bound that can never change. `status:
|
|
94
|
-
* stays reserved for an actually ordered pair.
|
|
123
|
+
* is uneditable the returned transition carries `close: true` instead of
|
|
124
|
+
* leaving the popup targeting a bound that can never change. `status:
|
|
125
|
+
* "complete"` stays reserved for an actually ordered pair.
|
|
95
126
|
* @param {string} date
|
|
96
127
|
* @param {(bound: "start" | "end") => boolean} [editable]
|
|
97
|
-
* @returns {
|
|
128
|
+
* @returns {RangeTransition}
|
|
98
129
|
*/
|
|
99
|
-
|
|
130
|
+
project(date, editable = () => true) {
|
|
100
131
|
if (!isDate(date)) throw new TypeError(`Invalid range activation: ${date}`);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
132
|
+
const { start, end } = this;
|
|
133
|
+
if (this.complete && (compareDates(date, start) < 0 || compareDates(date, end) > 0)) {
|
|
134
|
+
const endpoint = compareDates(date, start) < 0 ? "start" : "end";
|
|
135
|
+
if (!editable(endpoint)) return this._refuse(endpoint);
|
|
136
|
+
return {
|
|
137
|
+
status: "complete",
|
|
138
|
+
endpoint,
|
|
139
|
+
changedEndpoints: [endpoint],
|
|
140
|
+
range: endpoint === "start" ? { start: date, end } : { start, end: date },
|
|
141
|
+
activeEndpoint: endpoint,
|
|
142
|
+
};
|
|
107
143
|
}
|
|
108
144
|
if (this.activeEndpoint === "end") {
|
|
109
|
-
if (
|
|
110
|
-
|
|
145
|
+
if (start && !end && compareDates(date, start) < 0) {
|
|
146
|
+
// Second pick of a range being created: sort it instead of refusing.
|
|
147
|
+
// Both bounds move at once, which is why a transition has to describe
|
|
148
|
+
// the whole pair rather than a single endpoint.
|
|
149
|
+
if (!editable("start")) return this._refuse("start");
|
|
150
|
+
if (!editable("end")) return this._refuse("end");
|
|
151
|
+
return {
|
|
152
|
+
status: "complete",
|
|
153
|
+
endpoint: "end",
|
|
154
|
+
changedEndpoints: ["start", "end"],
|
|
155
|
+
range: { start: date, end: start },
|
|
156
|
+
activeEndpoint: "end",
|
|
157
|
+
};
|
|
111
158
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
159
|
+
// An existing pair keeps each bound's identity: an `end` before `start`
|
|
160
|
+
// is refused rather than rewriting `start` behind the user's back.
|
|
161
|
+
if (start && compareDates(date, start) < 0) return this._refuse("end");
|
|
162
|
+
if (!editable("end")) return this._refuse("end");
|
|
163
|
+
return {
|
|
164
|
+
status: start ? "complete" : "pending",
|
|
165
|
+
endpoint: "end",
|
|
166
|
+
changedEndpoints: ["end"],
|
|
167
|
+
range: { start, end: date },
|
|
168
|
+
activeEndpoint: "end",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (end && !start && compareDates(date, end) > 0) {
|
|
172
|
+
// Mirror of the end-side sort, for a range anchored by its end: no
|
|
173
|
+
// calendar interaction may produce an inverted pair, from either side.
|
|
174
|
+
if (!editable("start")) return this._refuse("start");
|
|
175
|
+
if (!editable("end")) return this._refuse("end");
|
|
176
|
+
return {
|
|
177
|
+
status: "complete",
|
|
178
|
+
endpoint: "start",
|
|
179
|
+
changedEndpoints: ["start", "end"],
|
|
180
|
+
range: { start: end, end: date },
|
|
181
|
+
activeEndpoint: "start",
|
|
182
|
+
};
|
|
115
183
|
}
|
|
116
|
-
if (!editable("start")) return
|
|
117
|
-
|
|
118
|
-
|
|
184
|
+
if (!editable("start")) return this._refuse("start");
|
|
185
|
+
const canContinue = editable("end");
|
|
186
|
+
return {
|
|
187
|
+
status: "pending",
|
|
188
|
+
endpoint: "start",
|
|
189
|
+
changedEndpoints: ["start"],
|
|
190
|
+
range: { start: date, end },
|
|
191
|
+
activeEndpoint: canContinue ? "end" : this.activeEndpoint,
|
|
119
192
|
// The range stays incomplete but must close: the next bound cannot
|
|
120
193
|
// receive the complementary pick.
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
194
|
+
...(canContinue ? {} : { close: true }),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Project a direct move of one bound (endpoint drag), without mutating.
|
|
200
|
+
*
|
|
201
|
+
* Only a complete, ordered pair offers two handles to grab, and a handle
|
|
202
|
+
* never crosses the other one: the candidate is clamped to the opposite
|
|
203
|
+
* bound, so the previewed band is exactly what a drop commits. Equality is
|
|
204
|
+
* allowed (a one-day range).
|
|
205
|
+
* @param {string} date
|
|
206
|
+
* @param {"start" | "end"} endpoint
|
|
207
|
+
* @param {(bound: "start" | "end") => boolean} [editable]
|
|
208
|
+
* @returns {RangeTransition}
|
|
209
|
+
*/
|
|
210
|
+
projectEndpoint(date, endpoint, editable = () => true) {
|
|
211
|
+
if (!isDate(date)) throw new TypeError(`Invalid range activation: ${date}`);
|
|
212
|
+
if (!this.complete || !editable(endpoint)) return this._refuse(endpoint);
|
|
213
|
+
const { start, end } = this;
|
|
214
|
+
const previous = endpoint === "start" ? start : end;
|
|
215
|
+
const target = endpoint === "start" ? earlier(date, end) : later(date, start);
|
|
216
|
+
return {
|
|
217
|
+
status: "complete",
|
|
218
|
+
endpoint,
|
|
219
|
+
changedEndpoints: target === previous ? [] : [endpoint],
|
|
220
|
+
range: endpoint === "start" ? { start: target, end } : { start, end: target },
|
|
221
|
+
activeEndpoint: endpoint,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Displayable band a candidate date would produce, or `null` when there is
|
|
227
|
+
* nothing to promise. Single source of truth for hover, keyboard and drag
|
|
228
|
+
* previews: the projection rules with no mutation and no event.
|
|
229
|
+
*
|
|
230
|
+
* Without a drag handle a preview only speaks during range creation, when
|
|
231
|
+
* exactly one bound is anchored and the other is still open. An empty range
|
|
232
|
+
* has no anchor to project from, and a complete pair is not a promise in
|
|
233
|
+
* progress — previewing it would repaint the committed band on every hover.
|
|
234
|
+
* @param {string} date
|
|
235
|
+
* @param {"" | "start" | "end"} [endpoint] drag handle, empty for a normal pick
|
|
236
|
+
* @param {(bound: "start" | "end") => boolean} [editable]
|
|
237
|
+
* @returns {DateRange | null}
|
|
238
|
+
*/
|
|
239
|
+
previewRange(date, endpoint = "", editable = () => true) {
|
|
240
|
+
if (!isDate(date)) return null;
|
|
241
|
+
if (!endpoint && Boolean(this.start) === Boolean(this.end)) return null;
|
|
242
|
+
const transition = endpoint
|
|
243
|
+
? this.projectEndpoint(date, endpoint, editable)
|
|
244
|
+
: this.project(date, editable);
|
|
245
|
+
if (transition.status === "refused") return null;
|
|
246
|
+
const { start, end } = transition.range;
|
|
247
|
+
if (!start || (end && compareDates(end, start) < 0)) return null;
|
|
248
|
+
return { start, end };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** @param {RangeTransition} transition @returns {RangeTransition} */
|
|
252
|
+
_apply(transition) {
|
|
253
|
+
if (transition.status === "refused") return transition;
|
|
254
|
+
this.start = transition.range.start;
|
|
255
|
+
this.end = transition.range.end;
|
|
256
|
+
this.activeEndpoint = transition.activeEndpoint;
|
|
257
|
+
return transition;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Apply one grid activation through the active endpoint.
|
|
262
|
+
* @param {string} date
|
|
263
|
+
* @param {(bound: "start" | "end") => boolean} [editable]
|
|
264
|
+
* @returns {RangeTransition}
|
|
265
|
+
*/
|
|
266
|
+
activate(date, editable = () => true) {
|
|
267
|
+
return this._apply(this.project(date, editable));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Apply a direct move of one bound (endpoint drag).
|
|
272
|
+
* @param {string} date
|
|
273
|
+
* @param {"start" | "end"} endpoint
|
|
274
|
+
* @param {(bound: "start" | "end") => boolean} [editable]
|
|
275
|
+
* @returns {RangeTransition}
|
|
276
|
+
*/
|
|
277
|
+
moveEndpoint(date, endpoint, editable = () => true) {
|
|
278
|
+
return this._apply(this.projectEndpoint(date, endpoint, editable));
|
|
125
279
|
}
|
|
126
280
|
}
|
|
127
281
|
|
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;
|
|
@@ -176,15 +177,68 @@ export function getMonthWeeks(value, options = {}) {
|
|
|
176
177
|
/** @type {string[][]} */
|
|
177
178
|
const weeks = [];
|
|
178
179
|
let cursor = start;
|
|
179
|
-
|
|
180
|
+
for (;;) {
|
|
180
181
|
const week = [];
|
|
181
182
|
for (let i = 0; i < 7; i++) week.push(addDays(cursor, i));
|
|
182
183
|
weeks.push(week);
|
|
184
|
+
// Stop from the built week's own end: advancing the cursor past the final
|
|
185
|
+
// week (e.g. from 9999-12-25 to 10000-01-01) would leave the canonical
|
|
186
|
+
// window even when every emitted day is valid.
|
|
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
|
+
}
|
|
183
192
|
cursor = addDays(cursor, 7);
|
|
184
193
|
}
|
|
185
194
|
return weeks;
|
|
186
195
|
}
|
|
187
196
|
|
|
197
|
+
/**
|
|
198
|
+
* 0-based civil day count since 0001-01-01. Used to compare instants that may
|
|
199
|
+
* fall one day outside the canonical 0001..9999 window without building an
|
|
200
|
+
* invalid ISO string.
|
|
201
|
+
* @param {number} year @param {number} month @param {number} day
|
|
202
|
+
*/
|
|
203
|
+
function dayOrdinal(year, month, day) {
|
|
204
|
+
const base = toUTCDate({ year: 1, month: 1, day: 1 });
|
|
205
|
+
return Math.round((toUTCDate({ year, month, day }).getTime() - base.getTime()) / 86400000);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Nearest month whose true civil weeks stay inside 0001-01-01..9999-12-31, so a
|
|
210
|
+
* grid can always be built. A month's covering weeks can reach a few days
|
|
211
|
+
* before or after it, so the earliest and latest displayable months depend on
|
|
212
|
+
* `firstDay`.
|
|
213
|
+
* @param {string} value YYYY-MM or YYYY-MM-DD
|
|
214
|
+
* @param {number} [firstDay]
|
|
215
|
+
* @returns {string} YYYY-MM
|
|
216
|
+
*/
|
|
217
|
+
export function clampDisplayMonth(value, firstDay = 1) {
|
|
218
|
+
const normalized = normalizeFirstDay(firstDay);
|
|
219
|
+
const maxOrdinal = dayOrdinal(9999, 12, 31);
|
|
220
|
+
let key = monthKey(value);
|
|
221
|
+
for (;;) {
|
|
222
|
+
const parsed = parseDate(`${key}-01`);
|
|
223
|
+
if (!parsed) throw new TypeError(`Invalid civil date/month: ${value}`);
|
|
224
|
+
const startOrdinal = dayOrdinal(parsed.year, parsed.month, 1);
|
|
225
|
+
const startOffset = (toUTCDate(parsed).getUTCDay() - normalized + 7) % 7;
|
|
226
|
+
if (startOrdinal - startOffset < 0) {
|
|
227
|
+
key = shiftMonth(key, 1);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const lastDay = daysInMonth(parsed.year, parsed.month);
|
|
231
|
+
const endParts = { year: parsed.year, month: parsed.month, day: lastDay };
|
|
232
|
+
const endOrdinal = startOrdinal + lastDay - 1;
|
|
233
|
+
const endOffset = (toUTCDate(endParts).getUTCDay() - normalized + 7) % 7;
|
|
234
|
+
if (endOrdinal - endOffset + 6 > maxOrdinal) {
|
|
235
|
+
key = shiftMonth(key, -1);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
return key;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
188
242
|
/** @param {string} value @param {string} [min] @param {string} [max] */
|
|
189
243
|
export function clampDate(value, min = "", max = "") {
|
|
190
244
|
if (!isDate(value)) throw new TypeError(`Invalid civil date: ${value}`);
|
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,17 +39,22 @@ 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
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} text @param {string} locale
|
|
52
|
+
* @param {{trim?: boolean}} [options] `trim` defaults to true; literal segments
|
|
53
|
+
* must keep their whitespace so the format's own spacing can be matched.
|
|
54
|
+
*/
|
|
55
|
+
function normalizeDigits(text, locale, options = {}) {
|
|
56
|
+
let result = String(text || "").replace(BIDI, "");
|
|
57
|
+
if (options.trim !== false) result = result.trim();
|
|
26
58
|
for (const [local, ascii] of digitMap(locale)) result = result.split(local).join(ascii);
|
|
27
59
|
return result;
|
|
28
60
|
}
|
|
@@ -30,7 +62,7 @@ function normalizeDigits(text, locale) {
|
|
|
30
62
|
/** @param {string} value @param {string} [locale] */
|
|
31
63
|
export function formatLongDate(value, locale = "") {
|
|
32
64
|
const resolved = resolveLocale(locale);
|
|
33
|
-
return
|
|
65
|
+
return dateFormatter(resolved, {
|
|
34
66
|
calendar: "gregory",
|
|
35
67
|
weekday: "long",
|
|
36
68
|
year: "numeric",
|
|
@@ -43,7 +75,7 @@ export function formatLongDate(value, locale = "") {
|
|
|
43
75
|
/** @param {string} value @param {string} [locale] */
|
|
44
76
|
export function formatMonthYear(value, locale = "") {
|
|
45
77
|
const resolved = resolveLocale(locale);
|
|
46
|
-
return
|
|
78
|
+
return dateFormatter(resolved, {
|
|
47
79
|
calendar: "gregory",
|
|
48
80
|
year: "numeric",
|
|
49
81
|
month: "long",
|
|
@@ -62,7 +94,7 @@ export function formatMonthYear(value, locale = "") {
|
|
|
62
94
|
export function monthYearOrder(locale = "") {
|
|
63
95
|
const resolved = resolveLocale(locale);
|
|
64
96
|
try {
|
|
65
|
-
const parts =
|
|
97
|
+
const parts = dateFormatter(resolved, {
|
|
66
98
|
calendar: "gregory",
|
|
67
99
|
year: "numeric",
|
|
68
100
|
month: "long",
|
|
@@ -89,7 +121,7 @@ export function monthYearOrder(locale = "") {
|
|
|
89
121
|
/** @param {string} [locale] @param {"long"|"short"} [style] */
|
|
90
122
|
export function monthNames(locale = "", style = "long") {
|
|
91
123
|
const resolved = resolveLocale(locale);
|
|
92
|
-
const formatter =
|
|
124
|
+
const formatter = dateFormatter(resolved, { calendar: "gregory", month: style, timeZone: "UTC" });
|
|
93
125
|
return Array.from({ length: 12 }, (_, index) =>
|
|
94
126
|
formatter.format(toIntlDate(`2026-${String(index + 1).padStart(2, "0")}-15`)),
|
|
95
127
|
);
|
|
@@ -102,7 +134,7 @@ export function monthNames(locale = "", style = "long") {
|
|
|
102
134
|
*/
|
|
103
135
|
export function weekdayNames(locale = "", firstDay = 1, style = "short") {
|
|
104
136
|
const resolved = resolveLocale(locale);
|
|
105
|
-
const formatter =
|
|
137
|
+
const formatter = dateFormatter(resolved, {
|
|
106
138
|
calendar: "gregory",
|
|
107
139
|
weekday: style,
|
|
108
140
|
timeZone: "UTC",
|
|
@@ -128,7 +160,7 @@ function escapeRegExp(value) {
|
|
|
128
160
|
*/
|
|
129
161
|
export function createDateAdapter(locale = "") {
|
|
130
162
|
const resolved = resolveLocale(locale);
|
|
131
|
-
const formatter =
|
|
163
|
+
const formatter = dateFormatter(resolved, {
|
|
132
164
|
calendar: "gregory",
|
|
133
165
|
year: "numeric",
|
|
134
166
|
month: "2-digit",
|
|
@@ -150,8 +182,11 @@ export function createDateAdapter(locale = "") {
|
|
|
150
182
|
.map((part) => {
|
|
151
183
|
if (part.type === "day" || part.type === "month") return "(\\d{1,2})";
|
|
152
184
|
if (part.type === "year") return "(\\d{4})";
|
|
153
|
-
|
|
154
|
-
|
|
185
|
+
// Literals keep their own spacing, so a run of blanks becomes an optional
|
|
186
|
+
// separator: locales such as ko-KR ("2026. 09. 06.") or bg-BG ("... г.")
|
|
187
|
+
// format with spaces the user may omit or retype.
|
|
188
|
+
const literal = normalizeDigits(part.value, resolved, { trim: false });
|
|
189
|
+
return escapeRegExp(literal).replace(/\s+/g, "\\s*");
|
|
155
190
|
})
|
|
156
191
|
.join("");
|
|
157
192
|
const regex = new RegExp(`^\\s*${pattern}\\s*$`);
|
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 });
|