@lekoala/date-picker 0.3.0 → 0.4.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 +8 -6
- package/custom-elements.json +20 -0
- package/dist/date-picker.css +32 -32
- package/dist/date-picker.js +493 -96
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +46 -46
- package/dist/types/date-calendar.d.ts +34 -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 +91 -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 +10 -0
- package/dist/types/date.d.ts.map +1 -1
- package/dist/types/intl.d.ts.map +1 -1
- package/docs/ACCESSIBILITY.md +6 -2
- package/docs/API.md +51 -1
- package/docs/DECISIONS.md +17 -4
- package/docs/MIGRATION-FLATPICKR.md +1 -1
- package/docs/TESTING.md +1 -1
- package/docs/USE_CASES.md +3 -0
- package/package.json +6 -6
- package/src/date-calendar.js +145 -42
- package/src/date-field.js +8 -4
- package/src/date-picker.css +32 -32
- package/src/date-picker.js +345 -37
- package/src/date-range.js +184 -30
- package/src/date.js +50 -1
- package/src/intl.js +13 -7
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
|
@@ -176,15 +176,64 @@ export function getMonthWeeks(value, options = {}) {
|
|
|
176
176
|
/** @type {string[][]} */
|
|
177
177
|
const weeks = [];
|
|
178
178
|
let cursor = start;
|
|
179
|
-
|
|
179
|
+
for (;;) {
|
|
180
180
|
const week = [];
|
|
181
181
|
for (let i = 0; i < 7; i++) week.push(addDays(cursor, i));
|
|
182
182
|
weeks.push(week);
|
|
183
|
+
// Stop from the built week's own end: advancing the cursor past the final
|
|
184
|
+
// week (e.g. from 9999-12-25 to 10000-01-01) would leave the canonical
|
|
185
|
+
// window even when every emitted day is valid.
|
|
186
|
+
if (compareDates(week[6], end) >= 0) break;
|
|
183
187
|
cursor = addDays(cursor, 7);
|
|
184
188
|
}
|
|
185
189
|
return weeks;
|
|
186
190
|
}
|
|
187
191
|
|
|
192
|
+
/**
|
|
193
|
+
* 0-based civil day count since 0001-01-01. Used to compare instants that may
|
|
194
|
+
* fall one day outside the canonical 0001..9999 window without building an
|
|
195
|
+
* invalid ISO string.
|
|
196
|
+
* @param {number} year @param {number} month @param {number} day
|
|
197
|
+
*/
|
|
198
|
+
function dayOrdinal(year, month, day) {
|
|
199
|
+
const base = toUTCDate({ year: 1, month: 1, day: 1 });
|
|
200
|
+
return Math.round((toUTCDate({ year, month, day }).getTime() - base.getTime()) / 86400000);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Nearest month whose true civil weeks stay inside 0001-01-01..9999-12-31, so a
|
|
205
|
+
* grid can always be built. A month's covering weeks can reach a few days
|
|
206
|
+
* before or after it, so the earliest and latest displayable months depend on
|
|
207
|
+
* `firstDay`.
|
|
208
|
+
* @param {string} value YYYY-MM or YYYY-MM-DD
|
|
209
|
+
* @param {number} [firstDay]
|
|
210
|
+
* @returns {string} YYYY-MM
|
|
211
|
+
*/
|
|
212
|
+
export function clampDisplayMonth(value, firstDay = 1) {
|
|
213
|
+
const normalized = normalizeFirstDay(firstDay);
|
|
214
|
+
const maxOrdinal = dayOrdinal(9999, 12, 31);
|
|
215
|
+
let key = monthKey(value);
|
|
216
|
+
for (;;) {
|
|
217
|
+
const parsed = parseDate(`${key}-01`);
|
|
218
|
+
if (!parsed) throw new TypeError(`Invalid civil date/month: ${value}`);
|
|
219
|
+
const startOrdinal = dayOrdinal(parsed.year, parsed.month, 1);
|
|
220
|
+
const startOffset = (toUTCDate(parsed).getUTCDay() - normalized + 7) % 7;
|
|
221
|
+
if (startOrdinal - startOffset < 0) {
|
|
222
|
+
key = shiftMonth(key, 1);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const lastDay = daysInMonth(parsed.year, parsed.month);
|
|
226
|
+
const endParts = { year: parsed.year, month: parsed.month, day: lastDay };
|
|
227
|
+
const endOrdinal = startOrdinal + lastDay - 1;
|
|
228
|
+
const endOffset = (toUTCDate(endParts).getUTCDay() - normalized + 7) % 7;
|
|
229
|
+
if (endOrdinal - endOffset + 6 > maxOrdinal) {
|
|
230
|
+
key = shiftMonth(key, -1);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
return key;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
188
237
|
/** @param {string} value @param {string} [min] @param {string} [max] */
|
|
189
238
|
export function clampDate(value, min = "", max = "") {
|
|
190
239
|
if (!isDate(value)) throw new TypeError(`Invalid civil date: ${value}`);
|
package/src/intl.js
CHANGED
|
@@ -18,11 +18,14 @@ function digitMap(locale) {
|
|
|
18
18
|
return map;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} text @param {string} locale
|
|
23
|
+
* @param {{trim?: boolean}} [options] `trim` defaults to true; literal segments
|
|
24
|
+
* must keep their whitespace so the format's own spacing can be matched.
|
|
25
|
+
*/
|
|
26
|
+
function normalizeDigits(text, locale, options = {}) {
|
|
27
|
+
let result = String(text || "").replace(BIDI, "");
|
|
28
|
+
if (options.trim !== false) result = result.trim();
|
|
26
29
|
for (const [local, ascii] of digitMap(locale)) result = result.split(local).join(ascii);
|
|
27
30
|
return result;
|
|
28
31
|
}
|
|
@@ -150,8 +153,11 @@ export function createDateAdapter(locale = "") {
|
|
|
150
153
|
.map((part) => {
|
|
151
154
|
if (part.type === "day" || part.type === "month") return "(\\d{1,2})";
|
|
152
155
|
if (part.type === "year") return "(\\d{4})";
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
// Literals keep their own spacing, so a run of blanks becomes an optional
|
|
157
|
+
// separator: locales such as ko-KR ("2026. 09. 06.") or bg-BG ("... г.")
|
|
158
|
+
// format with spaces the user may omit or retype.
|
|
159
|
+
const literal = normalizeDigits(part.value, resolved, { trim: false });
|
|
160
|
+
return escapeRegExp(literal).replace(/\s+/g, "\\s*");
|
|
155
161
|
})
|
|
156
162
|
.join("");
|
|
157
163
|
const regex = new RegExp(`^\\s*${pattern}\\s*$`);
|