@lekoala/date-picker 0.1.1 → 0.2.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.
@@ -5,11 +5,12 @@ import { DateFieldController } from "./date-field.js";
5
5
  import { DateRangeController, normalizeRange } from "./date-range.js";
6
6
  import { createDateAdapter, formatLongDate, resolveLocale } from "./intl.js";
7
7
  import { getDefaultMessages } from "./messages.js";
8
+ import { compareTimes, isTime } from "./time.js";
8
9
 
9
10
  let uid = 0;
10
11
 
11
12
  export class DatePickerElement extends HTMLElement {
12
- static observedAttributes = ["value", "locale", "min", "max", "open-on-focus"];
13
+ static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
13
14
 
14
15
  constructor() {
15
16
  super();
@@ -25,6 +26,10 @@ export class DatePickerElement extends HTMLElement {
25
26
  /** @type {DateRangeController | null} */
26
27
  this._range = null;
27
28
  this._orderTags = new Set();
29
+ /** Native time companions, [start, end]. @type {[HTMLInputElement | null, HTMLInputElement | null]} */
30
+ this._timeFields = [null, null];
31
+ /** Time inputs currently holding our order error. @type {Set<HTMLInputElement>} */
32
+ this._timeOrderOwned = new Set();
28
33
  /** @type {"" | "start" | "end"} */
29
34
  this._lastFocusEndpoint = "";
30
35
  this._rangeCommitId = 0;
@@ -67,13 +72,16 @@ export class DatePickerElement extends HTMLElement {
67
72
  }
68
73
 
69
74
  _connectSingle() {
70
- const input = this.querySelector(":scope > input:not([type=hidden])");
75
+ const input = this.querySelector(
76
+ ":scope > input:not([type=hidden]):not([type=time]):not([data-time-start]):not([data-time-end])",
77
+ );
71
78
  if (!(input instanceof HTMLInputElement)) {
72
79
  console.warn("<date-picker> expects a direct child text input");
73
80
  return;
74
81
  }
75
82
  this._connected = true;
76
83
  this._input = input;
84
+ this._discoverTimeFields();
77
85
  this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
78
86
  this._field.onAttributesChanged = () => this._syncInputState();
79
87
  this._field.confirm = (date) => this._confirmDate(date);
@@ -102,7 +110,37 @@ export class DatePickerElement extends HTMLElement {
102
110
  }
103
111
  }
104
112
 
113
+ /**
114
+ * Find optional native time companions. They stay fully native (no hidden
115
+ * input, no field controller); the picker only coordinates from/to order.
116
+ * A bad composition never breaks the date picker: warn and leave times alone.
117
+ */
118
+ _discoverTimeFields() {
119
+ this._timeFields = [null, null];
120
+ /** @type {["data-time-start", "data-time-end"]} */
121
+ const markers = ["data-time-start", "data-time-end"];
122
+ for (const [index, marker] of markers.entries()) {
123
+ const matches = this.querySelectorAll(`:scope > input[${marker}]`);
124
+ if (matches.length > 1) {
125
+ console.warn(`<date-picker> expects at most one direct child input[${marker}]; ignoring extras`);
126
+ }
127
+ const candidate = matches[0];
128
+ if (candidate == null) continue;
129
+ if (!(candidate instanceof HTMLInputElement) || candidate.type !== "time") {
130
+ console.warn(`<date-picker> input[${marker}] must be an <input type="time">; ignoring it`);
131
+ continue;
132
+ }
133
+ this._timeFields[index] = candidate;
134
+ }
135
+ if (this.querySelector(":scope > input[type=time]:not([data-time-start]):not([data-time-end])")) {
136
+ console.warn("<date-picker> time inputs need [data-time-start] or [data-time-end]; ignoring it");
137
+ }
138
+ }
139
+
105
140
  _connectRange() {
141
+ if (this.querySelector(":scope > input[data-time-start], :scope > input[data-time-end]")) {
142
+ console.warn("<date-picker range> time companions are not supported yet; leaving them native");
143
+ }
106
144
  const startInput = this.querySelector(":scope > input[data-range-start]");
107
145
  const endInput = this.querySelector(":scope > input[data-range-end]");
108
146
  if (!(startInput instanceof HTMLInputElement) || !(endInput instanceof HTMLInputElement)) {
@@ -156,6 +194,8 @@ export class DatePickerElement extends HTMLElement {
156
194
  this.hide(false);
157
195
  this._controller?.abort();
158
196
  this._controller = null;
197
+ this._clearTimeOrderValidity();
198
+ this._timeFields = [null, null];
159
199
  if (this._rangeMode()) {
160
200
  for (const field of this._fields || []) this._teardownField(field);
161
201
  this._fields = null;
@@ -248,6 +288,16 @@ export class DatePickerElement extends HTMLElement {
248
288
  else this.removeAttribute("locale");
249
289
  }
250
290
 
291
+ /** @public Visible month-name style forwarded to the popup calendar (`long` | `short`); anything else falls back to `long`. */
292
+ get monthFormat() {
293
+ return this.getAttribute("month-format") === "short" ? "short" : "long";
294
+ }
295
+
296
+ set monthFormat(value) {
297
+ if (value === "short") this.setAttribute("month-format", "short");
298
+ else this.removeAttribute("month-format");
299
+ }
300
+
251
301
  /** @public */
252
302
  get min() {
253
303
  const value = this.getAttribute("min") || "";
@@ -479,6 +529,12 @@ export class DatePickerElement extends HTMLElement {
479
529
  },
480
530
  { signal },
481
531
  );
532
+ for (const [index, timeInput] of this._timeFields.entries()) {
533
+ if (!timeInput) continue;
534
+ const endpoint = index === 1 ? "end" : "start";
535
+ timeInput.addEventListener("input", () => this._revalidateTimeOrder(endpoint), { signal });
536
+ timeInput.addEventListener("change", () => this._revalidateTimeOrder(endpoint), { signal });
537
+ }
482
538
  button.addEventListener(
483
539
  "click",
484
540
  (event) => {
@@ -721,6 +777,7 @@ export class DatePickerElement extends HTMLElement {
721
777
  calendar.locale = this.locale;
722
778
  calendar.min = this.min;
723
779
  calendar.max = this.max;
780
+ calendar.monthFormat = this.monthFormat;
724
781
  calendar.messages = this._messages;
725
782
  calendar.source = this._source;
726
783
  calendar.dateState = this._dateState;
@@ -959,6 +1016,47 @@ export class DatePickerElement extends HTMLElement {
959
1016
  }
960
1017
  }
961
1018
 
1019
+ /**
1020
+ * Single-mode from/to order on the shared date: `start <= end`.
1021
+ * The order error is attributed to the bound that was just modified; the
1022
+ * other bound only loses a stale order error, never its native validity.
1023
+ * Missing, empty or disabled times fall back to no order constraint: times
1024
+ * are never implicitly required. Equality stays valid (duration rules are
1025
+ * application-owned).
1026
+ * @param {"start" | "end"} which
1027
+ */
1028
+ _revalidateTimeOrder(which) {
1029
+ const [startInput, endInput] = this._timeFields;
1030
+ /** @type {["start", "end"]} */
1031
+ const bounds = ["start", "end"];
1032
+ const inputs = [startInput, endInput];
1033
+ const startValue = startInput && !startInput.disabled ? startInput.value.trim() : "";
1034
+ const endValue = endInput && !endInput.disabled ? endInput.value.trim() : "";
1035
+ const inverted =
1036
+ Boolean(startInput && endInput && startValue && endValue) &&
1037
+ isTime(startValue) &&
1038
+ isTime(endValue) &&
1039
+ compareTimes(endValue, startValue) < 0;
1040
+ for (const [index, bound] of bounds.entries()) {
1041
+ const input = inputs[index];
1042
+ if (!input) continue;
1043
+ const message = bound === "end" ? this._messages.rangeOrderEnd : this._messages.rangeOrderStart;
1044
+ const shouldHaveOrder = inverted && bound === which;
1045
+ if (shouldHaveOrder) {
1046
+ input.setCustomValidity(message);
1047
+ this._timeOrderOwned.add(input);
1048
+ } else if (this._timeOrderOwned.has(input)) {
1049
+ input.setCustomValidity("");
1050
+ this._timeOrderOwned.delete(input);
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ _clearTimeOrderValidity() {
1056
+ for (const input of this._timeOrderOwned) input.setCustomValidity("");
1057
+ this._timeOrderOwned.clear();
1058
+ }
1059
+
962
1060
  _restoreDefault() {
963
1061
  const field = this._field;
964
1062
  if (!field) return;
@@ -1051,10 +1149,15 @@ export class DatePickerElement extends HTMLElement {
1051
1149
  if (!input) return true;
1052
1150
  if (!input.value.trim()) {
1053
1151
  input.setCustomValidity("");
1054
- return input.checkValidity();
1152
+ } else {
1153
+ await this._commitText(false, { force: true });
1055
1154
  }
1056
- await this._commitText(false, { force: true });
1057
- return input.checkValidity();
1155
+ // Same start-then-end order as the range path, so a from/to inversion
1156
+ // ends reported on "end". A programmatic time change resyncs here too.
1157
+ this._revalidateTimeOrder("start");
1158
+ this._revalidateTimeOrder("end");
1159
+ const timesValid = this._timeFields.every((timeInput) => timeInput?.checkValidity() ?? true);
1160
+ return input.checkValidity() && timesValid;
1058
1161
  }
1059
1162
 
1060
1163
  /** @public @param {{moveFocus?:boolean}} [options] */
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  formatLongDate,
9
9
  formatMonthYear,
10
10
  monthNames,
11
+ monthYearOrder,
11
12
  resolveLocale,
12
13
  weekdayNames,
13
14
  } from "./intl.js";
package/src/intl.js CHANGED
@@ -51,6 +51,41 @@ export function formatMonthYear(value, locale = "") {
51
51
  }).format(toIntlDate(`${value.slice(0, 7)}-15`));
52
52
  }
53
53
 
54
+ /**
55
+ * Locale order of the visible month/year controls, derived from `Intl` so no
56
+ * `showMonthAfterYear`-style option is needed. Year-like parts cover the
57
+ * `relatedYear` / `yearName` variants some calendars expose; anything
58
+ * unrecognized falls back to month-then-year. This is independent of `dir`:
59
+ * the locale decides the linguistic order, direction only mirrors the layout.
60
+ * @param {string} [locale] @returns {["month", "year"] | ["year", "month"]}
61
+ */
62
+ export function monthYearOrder(locale = "") {
63
+ const resolved = resolveLocale(locale);
64
+ try {
65
+ const parts = new Intl.DateTimeFormat(resolved, {
66
+ calendar: "gregory",
67
+ year: "numeric",
68
+ month: "long",
69
+ timeZone: "UTC",
70
+ }).formatToParts(toIntlDate("2026-09-15"));
71
+ let monthIndex = -1;
72
+ let yearIndex = -1;
73
+ parts.forEach((part, index) => {
74
+ const type = /** @type {string} */ (part.type);
75
+ if (type === "month") {
76
+ if (monthIndex < 0) monthIndex = index;
77
+ } else if (type === "year" || type === "relatedYear" || type === "yearName") {
78
+ if (yearIndex < 0) yearIndex = index;
79
+ }
80
+ });
81
+ if (monthIndex >= 0 && yearIndex >= 0)
82
+ return yearIndex < monthIndex ? ["year", "month"] : ["month", "year"];
83
+ } catch {
84
+ // Fall through to the default below.
85
+ }
86
+ return ["month", "year"];
87
+ }
88
+
54
89
  /** @param {string} [locale] @param {"long"|"short"} [style] */
55
90
  export function monthNames(locale = "", style = "long") {
56
91
  const resolved = resolveLocale(locale);
package/src/time.js ADDED
@@ -0,0 +1,39 @@
1
+ const TIME_RE = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/;
2
+
3
+ /**
4
+ * Parse one canonical HTML time value (`HH:MM`, `HH:MM:SS`, `HH:MM:SS.fff`).
5
+ * @param {string} value
6
+ * @returns {{hour:number, minute:number, second:number, millisecond:number} | null}
7
+ */
8
+ function parseTimeParts(value) {
9
+ const match = TIME_RE.exec(String(value || ""));
10
+ if (!match) return null;
11
+ const hour = Number(match[1]);
12
+ const minute = Number(match[2]);
13
+ const second = match[3] === undefined ? 0 : Number(match[3]);
14
+ const millisecond = match[4] === undefined ? 0 : Number(match[4].padEnd(3, "0"));
15
+ if (hour > 23 || minute > 59 || second > 59 || millisecond > 999) return null;
16
+ return { hour, minute, second, millisecond };
17
+ }
18
+
19
+ /** Civil time check for canonical `input[type=time]` values. @param {string} value */
20
+ export function isTime(value) {
21
+ return parseTimeParts(value) !== null;
22
+ }
23
+
24
+ /** @param {{hour:number, minute:number, second:number, millisecond:number}} parts */
25
+ function toMillis(parts) {
26
+ return ((parts.hour * 60 + parts.minute) * 60 + parts.second) * 1000 + parts.millisecond;
27
+ }
28
+
29
+ /**
30
+ * Compare two canonical time values. `09:30` equals `09:30:00`.
31
+ * @param {string} a @param {string} b
32
+ */
33
+ export function compareTimes(a, b) {
34
+ const left = parseTimeParts(a);
35
+ const right = parseTimeParts(b);
36
+ if (!left || !right) throw new TypeError("compareTimes() expects HH:MM values");
37
+ const diff = toMillis(left) - toMillis(right);
38
+ return diff === 0 ? 0 : diff < 0 ? -1 : 1;
39
+ }