@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/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 (U9):
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
- * - otherwise activating through `end` refuses a date before `start`;
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
- * The business range may be temporarily incomplete or inverted; it is the
61
- * coordinator's job to forward only a displayable range to
62
- * `calendar.highlightedRange`.
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 {{start:string, end:string}} */
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
- * Process one grid activation through the active endpoint.
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 result carries `close: true` instead of leaving
93
- * the popup targeting a bound that can never change. `status: "complete"`
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 {{status: "pending" | "complete" | "refused", endpoint: "start" | "end", close?: boolean}}
128
+ * @returns {RangeTransition}
98
129
  */
99
- activate(date, editable = () => true) {
130
+ project(date, editable = () => true) {
100
131
  if (!isDate(date)) throw new TypeError(`Invalid range activation: ${date}`);
101
- if (this.complete && (compareDates(date, this.start) < 0 || compareDates(date, this.end) > 0)) {
102
- const endpoint = compareDates(date, this.start) < 0 ? "start" : "end";
103
- if (!editable(endpoint)) return { status: "refused", endpoint };
104
- this[endpoint] = date;
105
- this.activeEndpoint = endpoint;
106
- return { status: "complete", endpoint };
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 (this.start && compareDates(date, this.start) < 0) {
110
- return { status: "refused", endpoint: "end" };
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
- if (!editable("end")) return { status: "refused", endpoint: "end" };
113
- this.end = date;
114
- return { status: this.start ? "complete" : "pending", endpoint: "end" };
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 { status: "refused", endpoint: "start" };
117
- this.start = date;
118
- if (!editable("end")) {
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
- return { status: "pending", endpoint: "start", close: true };
122
- }
123
- this.activeEndpoint = "end";
124
- return { status: "pending", endpoint: "start" };
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
- while (compareDates(cursor, end) <= 0) {
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
- /** @param {string} text @param {string} locale */
22
- function normalizeDigits(text, locale) {
23
- let result = String(text || "")
24
- .replace(BIDI, "")
25
- .trim();
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
- const literal = normalizeDigits(part.value, resolved).replace(/\s+/g, " ");
154
- return escapeRegExp(literal).replace(/\\ /g, "\\s*");
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*$`);