@lekoala/date-picker 0.1.0 → 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.
@@ -9,6 +9,11 @@
9
9
  --dp-radius: 0.5rem;
10
10
  --dp-day-size: 2.75rem;
11
11
  --dp-gap: 0.25rem;
12
+ --dp-header-control-size: 2.25rem;
13
+ --dp-header-block-gap: 0.4rem;
14
+ --dp-nav-gap: calc(var(--dp-gap) * 2.5);
15
+ --dp-year-inline-size: 5.5rem;
16
+ --dp-day-radius: calc(var(--dp-radius) * 0.75);
12
17
  color: var(--dp-fg);
13
18
  font: inherit;
14
19
  }
@@ -39,22 +44,46 @@ date-calendar {
39
44
  .dp-calendar-header {
40
45
  display: grid;
41
46
  grid-template-columns: minmax(0, 1fr) auto auto auto;
42
- align-items: center;
47
+ /* Stretch (not center): once a UA natural height exceeds
48
+ --dp-header-control-size, the row grows to the tallest control and the
49
+ others stretch to match, so month/year/nav stay equal. */
50
+ align-items: stretch;
43
51
  gap: var(--dp-gap);
44
- margin-block-end: 0.4rem;
52
+ margin-block-end: var(--dp-header-block-gap);
53
+ }
54
+
55
+ /* Locale year-first order (Intl-derived, direction-independent): the flexible
56
+ track stays under the month select so the fixed-width year input never
57
+ stretches. Visual mirroring itself is left to the inherited `dir`. */
58
+ date-calendar[data-year-first] .dp-calendar-header {
59
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
45
60
  }
46
61
 
47
62
  .dp-calendar-header .dp-prev {
48
- margin-inline-start: calc(var(--dp-gap) * 2.5);
63
+ margin-inline-start: var(--dp-nav-gap);
49
64
  }
50
65
 
51
66
  .dp-calendar-header :where(button, select, input) {
52
- min-block-size: 2.25rem;
67
+ min-block-size: var(--dp-header-control-size);
68
+ box-sizing: border-box;
69
+ /* Shared vertical metrics: UA default padding/line-height differ per control
70
+ (up to ~13px apart), which only shows once --dp-header-control-size drops
71
+ below content size. */
72
+ padding-block: 0.15rem;
53
73
  border: 1px solid var(--dp-border);
54
74
  border-radius: calc(var(--dp-radius) * 0.75);
55
75
  background: var(--dp-bg);
56
76
  color: var(--dp-fg);
57
77
  font: inherit;
78
+ line-height: 1.2;
79
+ }
80
+
81
+ /* The select menulist carries UA-internal leading that author padding only
82
+ adds to (and it ignores line-height), so it gets zero author padding.
83
+ Equal outer heights come from the header grid's stretch alignment: the row
84
+ sizes to the tallest control and the others stretch to match. */
85
+ .dp-calendar-header select {
86
+ padding-block: 0;
58
87
  }
59
88
 
60
89
  .dp-calendar-header select,
@@ -65,16 +94,20 @@ date-calendar {
65
94
 
66
95
  .dp-month-select {
67
96
  inline-size: 100%;
97
+ /* Long month names in a narrow track (compact consumers, long locales)
98
+ would paint under the native caret: truncate with an ellipsis instead. */
99
+ overflow: hidden;
100
+ text-overflow: ellipsis;
68
101
  }
69
102
 
70
103
  .dp-year-input {
71
- inline-size: 5.5rem;
104
+ inline-size: var(--dp-year-inline-size);
72
105
  }
73
106
 
74
107
  .dp-nav {
75
108
  display: grid;
76
109
  place-items: center;
77
- inline-size: 2.25rem;
110
+ inline-size: var(--dp-header-control-size);
78
111
  padding: 0;
79
112
  font-size: 1.35rem;
80
113
  line-height: 1;
@@ -88,6 +121,14 @@ date-calendar {
88
121
  pointer-events: none;
89
122
  }
90
123
 
124
+ /* Inherited RTL mirrors the header layout via the grid; mirror the nav glyphs
125
+ so previous/next keep their visual sense. Actions stay chronological: no
126
+ button swap, no keyboard remapping. */
127
+ [dir="rtl"] .dp-nav svg,
128
+ html[dir="rtl"] .dp-nav svg {
129
+ transform: scaleX(-1);
130
+ }
131
+
91
132
  .dp-calendar-header :where(button, select, input):disabled,
92
133
  .dp-picker-button:disabled {
93
134
  cursor: not-allowed;
@@ -104,12 +145,9 @@ date-calendar {
104
145
  background: var(--dp-hover);
105
146
  }
106
147
 
107
- .dp-calendar-heading {
108
- margin: 0 0 0.35rem;
109
- font-size: 0.875rem;
110
- font-weight: 600;
111
- text-align: center;
112
- }
148
+ /* .dp-calendar-heading is always visually hidden: it names the grid via
149
+ aria-labelledby and stays the aria-live announcement channel while the
150
+ month/year selects render the same info visually. */
113
151
 
114
152
  .dp-grid {
115
153
  border-collapse: separate;
@@ -136,7 +174,7 @@ date-calendar {
136
174
  .dp-day {
137
175
  position: relative;
138
176
  border: 1px solid transparent;
139
- border-radius: calc(var(--dp-radius) * 0.75);
177
+ border-radius: var(--dp-day-radius);
140
178
  cursor: pointer;
141
179
  outline: none;
142
180
  vertical-align: middle;
@@ -145,10 +183,21 @@ date-calendar {
145
183
 
146
184
  .dp-day:focus-visible {
147
185
  outline: 2px solid var(--dp-accent);
148
- outline-offset: 1px;
186
+ outline-offset: 2px;
187
+ /* Separation gap between the cell fill and the ring: invisible on plain
188
+ days, but keeps the ring readable on accent-filled days (selected, range
189
+ endpoints) where an accent-on-accent outline would disappear. */
190
+ box-shadow: 0 0 0 2px var(--dp-bg);
149
191
  z-index: 1;
150
192
  }
151
193
 
194
+ /* Base tint for days outside the displayed month. It stays before the
195
+ selection states so selected/range endpoints keep their accent-contrast
196
+ text on the accent fill (equal specificity, source order decides). */
197
+ .dp-day[data-outside-month="true"] {
198
+ color: var(--dp-muted);
199
+ }
200
+
152
201
  .dp-day[data-selected="true"] {
153
202
  background: var(--dp-accent);
154
203
  color: var(--dp-accent-fg);
@@ -158,10 +207,6 @@ date-calendar {
158
207
  border-color: var(--dp-accent);
159
208
  }
160
209
 
161
- .dp-day[data-outside-month="true"] {
162
- color: var(--dp-muted);
163
- }
164
-
165
210
  .dp-day[data-disabled="true"] {
166
211
  cursor: not-allowed;
167
212
  opacity: 0.42;
@@ -204,6 +249,17 @@ date-calendar {
204
249
  font-variant-numeric: tabular-nums;
205
250
  }
206
251
 
252
+ /* A focused disabled day stays discoverable by keyboard: restore full opacity
253
+ so the focus outline renders at full strength, and mark the number with the
254
+ muted tone (same language as outside-month days) instead of the wash-out. */
255
+ .dp-day[data-disabled="true"]:focus-visible {
256
+ opacity: 1;
257
+ }
258
+
259
+ .dp-day[data-disabled="true"]:focus-visible .dp-day-number {
260
+ color: var(--dp-muted);
261
+ }
262
+
207
263
  .dp-day-extra {
208
264
  position: absolute;
209
265
  inset-inline: 2px;
@@ -238,16 +294,56 @@ date-picker > input:not([type="hidden"]) {
238
294
  min-inline-size: 0;
239
295
  }
240
296
 
297
+ /* Optional native time companions share the date row; authored inputs stay
298
+ consumer-styled, the picker only reserves their grid cells. Time tracks are
299
+ flexible (never `auto`) so a wide UA time control cannot squeeze the date. */
300
+ date-picker:has(> [data-time-start], > [data-time-end]) {
301
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
302
+ }
303
+
304
+ date-picker:has(> [data-time-start]):has(> [data-time-end]) {
305
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) minmax(0, 1fr);
306
+ }
307
+
308
+ date-picker > input[data-time-start] {
309
+ grid-column: 3;
310
+ grid-row: 1;
311
+ min-inline-size: 0;
312
+ inline-size: 100%;
313
+ margin-inline-start: var(--dp-gap);
314
+ }
315
+
316
+ date-picker > input[data-time-end] {
317
+ grid-column: 4;
318
+ grid-row: 1;
319
+ min-inline-size: 0;
320
+ inline-size: 100%;
321
+ }
322
+
323
+ /* From/to read as one joined control: square the inner corners, but only
324
+ when both companions are present so a lone time keeps its own radius. */
325
+ date-picker:has(> [data-time-start]):has(> [data-time-end]) > input[data-time-start] {
326
+ border-start-end-radius: 0;
327
+ border-end-end-radius: 0;
328
+ }
329
+
330
+ date-picker:has(> [data-time-start]):has(> [data-time-end]) > input[data-time-end] {
331
+ border-start-start-radius: 0;
332
+ border-end-start-radius: 0;
333
+ }
334
+
241
335
  date-picker[range] > input:not([type="hidden"]) {
242
336
  grid-column: 1;
243
337
  }
244
338
 
245
339
  date-picker[range] > input[data-range-start] {
246
340
  grid-row: 1;
341
+ border-end-start-radius: 0;
247
342
  }
248
343
 
249
344
  date-picker[range] > input[data-range-end] {
250
345
  grid-row: 2;
346
+ border-start-start-radius: 0;
251
347
  }
252
348
 
253
349
  .dp-picker-button {
@@ -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/date.js CHANGED
@@ -141,11 +141,21 @@ export function dayOfWeek(value) {
141
141
  return toUTCDate(parsed).getUTCDay();
142
142
  }
143
143
 
144
+ /**
145
+ * Canonical first weekday. 7 is the ISO-style Sunday alias of 0; values 1..6
146
+ * keep their current meaning. Any other value throws.
147
+ * @param {number} firstDay
148
+ */
149
+ export function normalizeFirstDay(firstDay) {
150
+ if (firstDay === 7) return 0;
151
+ if (!Number.isInteger(firstDay) || firstDay < 0 || firstDay > 6)
152
+ throw new RangeError("firstDay must be 0..6 (Sunday=0, 7 accepted as Sunday alias)");
153
+ return firstDay;
154
+ }
155
+
144
156
  /** @param {string} value @param {number} firstDay */
145
157
  export function startOfWeek(value, firstDay = 1) {
146
- if (!Number.isInteger(firstDay) || firstDay < 0 || firstDay > 6)
147
- throw new RangeError("firstDay must be 0..6");
148
- const offset = (dayOfWeek(value) - firstDay + 7) % 7;
158
+ const offset = (dayOfWeek(value) - normalizeFirstDay(firstDay) + 7) % 7;
149
159
  return addDays(value, -offset);
150
160
  }
151
161
 
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
@@ -1,4 +1,4 @@
1
- import { isDate, parseDate, toIntlDate, toISODate } from "./date.js";
1
+ import { isDate, normalizeFirstDay, parseDate, toIntlDate, toISODate } from "./date.js";
2
2
 
3
3
  const BIDI = /[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g;
4
4
 
@@ -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);
@@ -72,11 +107,12 @@ export function weekdayNames(locale = "", firstDay = 1, style = "short") {
72
107
  weekday: style,
73
108
  timeZone: "UTC",
74
109
  });
110
+ const normalized = normalizeFirstDay(firstDay);
75
111
  // 2026-01-04 is a Sunday.
76
112
  const sunday = toIntlDate("2026-01-04");
77
113
  return Array.from({ length: 7 }, (_, index) => {
78
114
  const date = new Date(sunday);
79
- date.setUTCDate(sunday.getUTCDate() + ((firstDay + index) % 7));
115
+ date.setUTCDate(sunday.getUTCDate() + ((normalized + index) % 7));
80
116
  return formatter.format(date);
81
117
  });
82
118
  }
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
+ }