@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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Small, native-first date picker and inline calendar primitives.
4
4
 
5
- > v0.1: the interaction contract, event names, public styling tokens and the component API are frozen — see [docs/DECISIONS.md](docs/DECISIONS.md). Internal DOM and class names are `.dp-*` implementation details and stay private.
5
+ > v0.2: the interaction contract, event names, public styling tokens and the component API are frozen — see [docs/DECISIONS.md](docs/DECISIONS.md). Internal DOM and class names are `.dp-*` implementation details and stay private.
6
6
 
7
7
  This project fills the gap between a normal editable date field and a full scheduling calendar. A plain date input remains the right answer for simple CMS/editing forms. This component becomes useful when a date has context: availability, disabled days, annotations, remote state, start/end relationships, or a mini calendar that navigates another view.
8
8
 
@@ -77,6 +77,24 @@ const cleanup = linkDateRange(startPicker, endPicker);
77
77
 
78
78
  The relationship applies `end >= start` and `start <= end` as effective bounds. Neither picker knows about its sibling.
79
79
 
80
+ ## Date + time
81
+
82
+ A date pairs with one or two native time companions. The picker owns the civil date; the time inputs own their civil time values:
83
+
84
+ ```html
85
+ <date-picker>
86
+ <input name="appointment[date]">
87
+ <input type="time" data-time-start name="appointment[time]">
88
+ </date-picker>
89
+ ```
90
+
91
+ ```text
92
+ appointment[date] = 2026-09-10
93
+ appointment[time] = 09:30
94
+ ```
95
+
96
+ The application/server combines these civil values and applies the relevant timezone when a datetime/instant is needed. The picker never constructs a combined datetime value.
97
+
80
98
  ## Availability and per-day metadata
81
99
 
82
100
  The calendar accepts a source function or `{ load() }` object with the same abortable shape used elsewhere in LeKoala components:
@@ -194,7 +212,7 @@ The default ESM exports point at `src/`, matching the native-first development s
194
212
 
195
213
  ## Scope
196
214
 
197
- Included in v0.1:
215
+ Included in v0.2:
198
216
 
199
217
  - inline single-date selection;
200
218
  - mini-calendar/navigation-only use;
@@ -205,15 +223,20 @@ Included in v0.1:
205
223
  - presentation-only range band (`highlightedRange`);
206
224
  - shared-calendar two-field range picker (`<date-picker range>`);
207
225
  - linked start/end pickers (`linkDateRange`);
226
+ - native time companions (one or two `input[type=time]` beside the date);
227
+ - `Intl` month/year control order + `month-format="short"`;
228
+ - RTL through the inherited `dir` (no `isRTL` option);
229
+ - readable keyboard focus on disabled/selected days;
230
+ - compact-consumer CSS tokens (`--dp-header-control-size`, `--dp-day-radius`, …);
208
231
  - async per-day state with cancellation;
209
232
  - custom day decorations that cannot replace the accessible cell;
210
233
  - fixed six-row presentation as an opt-in;
211
234
  - ISO week numbers;
212
235
  - keyboard and focus contracts.
213
236
 
214
- Deliberately out of scope for v0.1:
237
+ Deliberately out of scope for v0.2:
215
238
 
216
- - time or datetime picking;
239
+ - custom time-picker UI and combined datetime values (only native `input[type=time]` companions are supported);
217
240
  - timezones;
218
241
  - in-place single-calendar range *selection interaction* (use the two-field picker);
219
242
  - multi-date selection;
@@ -37,6 +37,9 @@
37
37
  },
38
38
  {
39
39
  "name": "selection"
40
+ },
41
+ {
42
+ "name": "month-format"
40
43
  }
41
44
  ],
42
45
  "members": [
@@ -72,6 +75,10 @@
72
75
  "kind": "getter",
73
76
  "name": "selection"
74
77
  },
78
+ {
79
+ "kind": "getter",
80
+ "name": "monthFormat"
81
+ },
75
82
  {
76
83
  "kind": "getter",
77
84
  "name": "highlightedRange"
@@ -190,6 +197,21 @@
190
197
  },
191
198
  {
192
199
  "name": "--dp-gap"
200
+ },
201
+ {
202
+ "name": "--dp-header-control-size"
203
+ },
204
+ {
205
+ "name": "--dp-header-block-gap"
206
+ },
207
+ {
208
+ "name": "--dp-nav-gap"
209
+ },
210
+ {
211
+ "name": "--dp-year-inline-size"
212
+ },
213
+ {
214
+ "name": "--dp-day-radius"
193
215
  }
194
216
  ]
195
217
  }
@@ -228,6 +250,9 @@
228
250
  },
229
251
  {
230
252
  "name": "open-on-focus"
253
+ },
254
+ {
255
+ "name": "month-format"
231
256
  }
232
257
  ],
233
258
  "members": [
@@ -251,6 +276,10 @@
251
276
  "kind": "getter",
252
277
  "name": "locale"
253
278
  },
279
+ {
280
+ "kind": "getter",
281
+ "name": "monthFormat"
282
+ },
254
283
  {
255
284
  "kind": "getter",
256
285
  "name": "min"
@@ -347,6 +376,21 @@
347
376
  },
348
377
  {
349
378
  "name": "--dp-gap"
379
+ },
380
+ {
381
+ "name": "--dp-header-control-size"
382
+ },
383
+ {
384
+ "name": "--dp-header-block-gap"
385
+ },
386
+ {
387
+ "name": "--dp-nav-gap"
388
+ },
389
+ {
390
+ "name": "--dp-year-inline-size"
391
+ },
392
+ {
393
+ "name": "--dp-day-radius"
350
394
  }
351
395
  ]
352
396
  }
@@ -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;
@@ -133,7 +174,7 @@ date-calendar {
133
174
  .dp-day {
134
175
  position: relative;
135
176
  border: 1px solid transparent;
136
- border-radius: calc(var(--dp-radius) * 0.75);
177
+ border-radius: var(--dp-day-radius);
137
178
  cursor: pointer;
138
179
  outline: none;
139
180
  vertical-align: middle;
@@ -142,10 +183,21 @@ date-calendar {
142
183
 
143
184
  .dp-day:focus-visible {
144
185
  outline: 2px solid var(--dp-accent);
145
- 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);
146
191
  z-index: 1;
147
192
  }
148
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
+
149
201
  .dp-day[data-selected="true"] {
150
202
  background: var(--dp-accent);
151
203
  color: var(--dp-accent-fg);
@@ -155,10 +207,6 @@ date-calendar {
155
207
  border-color: var(--dp-accent);
156
208
  }
157
209
 
158
- .dp-day[data-outside-month="true"] {
159
- color: var(--dp-muted);
160
- }
161
-
162
210
  .dp-day[data-disabled="true"] {
163
211
  cursor: not-allowed;
164
212
  opacity: 0.42;
@@ -201,6 +249,17 @@ date-calendar {
201
249
  font-variant-numeric: tabular-nums;
202
250
  }
203
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
+
204
263
  .dp-day-extra {
205
264
  position: absolute;
206
265
  inset-inline: 2px;
@@ -235,16 +294,56 @@ date-picker > input:not([type="hidden"]) {
235
294
  min-inline-size: 0;
236
295
  }
237
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
+
238
335
  date-picker[range] > input:not([type="hidden"]) {
239
336
  grid-column: 1;
240
337
  }
241
338
 
242
339
  date-picker[range] > input[data-range-start] {
243
340
  grid-row: 1;
341
+ border-end-start-radius: 0;
244
342
  }
245
343
 
246
344
  date-picker[range] > input[data-range-end] {
247
345
  grid-row: 2;
346
+ border-start-start-radius: 0;
248
347
  }
249
348
 
250
349
  .dp-picker-button {
@@ -1,4 +1,4 @@
1
- /*** @lekoala/date-picker v0.1.1 - https://github.com/lekoala/date-picker ***/
1
+ /*** @lekoala/date-picker v0.2.0 - https://github.com/lekoala/date-picker ***/
2
2
  (() => {
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -387,6 +387,32 @@
387
387
  timeZone: "UTC"
388
388
  }).format(toIntlDate(`${value.slice(0, 7)}-15`));
389
389
  }
390
+ function monthYearOrder(locale = "") {
391
+ const resolved = resolveLocale(locale);
392
+ try {
393
+ const parts = new Intl.DateTimeFormat(resolved, {
394
+ calendar: "gregory",
395
+ year: "numeric",
396
+ month: "long",
397
+ timeZone: "UTC"
398
+ }).formatToParts(toIntlDate("2026-09-15"));
399
+ let monthIndex = -1;
400
+ let yearIndex = -1;
401
+ parts.forEach((part, index) => {
402
+ const type = part.type;
403
+ if (type === "month") {
404
+ if (monthIndex < 0)
405
+ monthIndex = index;
406
+ } else if (type === "year" || type === "relatedYear" || type === "yearName") {
407
+ if (yearIndex < 0)
408
+ yearIndex = index;
409
+ }
410
+ });
411
+ if (monthIndex >= 0 && yearIndex >= 0)
412
+ return yearIndex < monthIndex ? ["year", "month"] : ["month", "year"];
413
+ } catch {}
414
+ return ["month", "year"];
415
+ }
390
416
  function monthNames(locale = "", style = "long") {
391
417
  const resolved = resolveLocale(locale);
392
418
  const formatter = new Intl.DateTimeFormat(resolved, { calendar: "gregory", month: style, timeZone: "UTC" });
@@ -567,7 +593,8 @@
567
593
  "locale",
568
594
  "fixed-weeks",
569
595
  "show-week-numbers",
570
- "selection"
596
+ "selection",
597
+ "month-format"
571
598
  ];
572
599
  constructor() {
573
600
  super();
@@ -689,6 +716,15 @@
689
716
  set selection(value) {
690
717
  this.setAttribute("selection", value === "none" ? "none" : "single");
691
718
  }
719
+ get monthFormat() {
720
+ return this.getAttribute("month-format") === "short" ? "short" : "long";
721
+ }
722
+ set monthFormat(value) {
723
+ if (value === "short")
724
+ this.setAttribute("month-format", "short");
725
+ else
726
+ this.removeAttribute("month-format");
727
+ }
692
728
  get highlightedRange() {
693
729
  return { ...this._highlightedRange };
694
730
  }
@@ -1109,7 +1145,7 @@
1109
1145
  const display = this.display;
1110
1146
  const displayYear = yearOf(display);
1111
1147
  const displayMonth = Number(display.slice(5, 7));
1112
- const names = monthNames(locale, "long");
1148
+ const names = monthNames(locale, this.monthFormat);
1113
1149
  const weekdayShort = weekdayNames(locale, this.firstDay, "short");
1114
1150
  const weekdayLong = weekdayNames(locale, this.firstDay, "long");
1115
1151
  const weeks = this._weeks();
@@ -1154,14 +1190,18 @@
1154
1190
  }).join("");
1155
1191
  const focusedElement = document.activeElement;
1156
1192
  const focusKey = focusedElement instanceof HTMLElement && this.contains(focusedElement) ? this._focusTargetKey(focusedElement) : "";
1193
+ const yearFirst = monthYearOrder(locale)[0] === "year";
1194
+ this.toggleAttribute("data-year-first", yearFirst);
1195
+ const monthControl = `<label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
1196
+ <select id="${this._id}-month" class="dp-month-select" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>`;
1197
+ const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
1198
+ <input id="${this._id}-year" class="dp-year-input" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">`;
1157
1199
  this._rendering = true;
1158
1200
  this.innerHTML = `
1159
1201
  <div class="dp-calendar-shell">
1160
1202
  <div class="dp-calendar-header">
1161
- <label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
1162
- <select id="${this._id}-month" class="dp-month-select" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>
1163
- <label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
1164
- <input id="${this._id}-year" class="dp-year-input" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">
1203
+ ${yearFirst ? yearControl : monthControl}
1204
+ ${yearFirst ? monthControl : yearControl}
1165
1205
  <button type="button" class="dp-nav dp-prev" data-calendar-action="previous" aria-label="${escapeHtml(this._messages.previousMonth)}"${prevDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m10 4-4 4 4 4"/></svg></button>
1166
1206
  <button type="button" class="dp-nav dp-next" data-calendar-action="next" aria-label="${escapeHtml(this._messages.nextMonth)}"${nextDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg></button>
1167
1207
  </div>
@@ -1174,7 +1214,11 @@
1174
1214
  this._rendering = false;
1175
1215
  if (focusKey) {
1176
1216
  queueMicrotask(() => {
1177
- const target = this.querySelector(focusKey);
1217
+ let selector = focusKey;
1218
+ if (selector.startsWith(".dp-day[data-date=")) {
1219
+ selector = `.dp-day[data-date="${CSS.escape(this._model.focused)}"]`;
1220
+ }
1221
+ const target = this.querySelector(selector);
1178
1222
  if (target instanceof HTMLElement)
1179
1223
  target.focus();
1180
1224
  });
@@ -1697,11 +1741,40 @@
1697
1741
  }
1698
1742
  }
1699
1743
 
1744
+ // src/time.js
1745
+ var TIME_RE = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/;
1746
+ function parseTimeParts(value) {
1747
+ const match = TIME_RE.exec(String(value || ""));
1748
+ if (!match)
1749
+ return null;
1750
+ const hour = Number(match[1]);
1751
+ const minute = Number(match[2]);
1752
+ const second = match[3] === undefined ? 0 : Number(match[3]);
1753
+ const millisecond = match[4] === undefined ? 0 : Number(match[4].padEnd(3, "0"));
1754
+ if (hour > 23 || minute > 59 || second > 59 || millisecond > 999)
1755
+ return null;
1756
+ return { hour, minute, second, millisecond };
1757
+ }
1758
+ function isTime(value) {
1759
+ return parseTimeParts(value) !== null;
1760
+ }
1761
+ function toMillis(parts) {
1762
+ return ((parts.hour * 60 + parts.minute) * 60 + parts.second) * 1000 + parts.millisecond;
1763
+ }
1764
+ function compareTimes(a, b) {
1765
+ const left = parseTimeParts(a);
1766
+ const right = parseTimeParts(b);
1767
+ if (!left || !right)
1768
+ throw new TypeError("compareTimes() expects HH:MM values");
1769
+ const diff = toMillis(left) - toMillis(right);
1770
+ return diff === 0 ? 0 : diff < 0 ? -1 : 1;
1771
+ }
1772
+
1700
1773
  // src/date-picker.js
1701
1774
  var uid2 = 0;
1702
1775
 
1703
1776
  class DatePickerElement extends HTMLElement {
1704
- static observedAttributes = ["value", "locale", "min", "max", "open-on-focus"];
1777
+ static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
1705
1778
  constructor() {
1706
1779
  super();
1707
1780
  this._id = `date-picker-${++uid2}`;
@@ -1713,6 +1786,8 @@
1713
1786
  this._fields = null;
1714
1787
  this._range = null;
1715
1788
  this._orderTags = new Set;
1789
+ this._timeFields = [null, null];
1790
+ this._timeOrderOwned = new Set;
1716
1791
  this._lastFocusEndpoint = "";
1717
1792
  this._rangeCommitId = 0;
1718
1793
  this._pendingIntents = new Map;
@@ -1746,13 +1821,14 @@
1746
1821
  this._connectSingle();
1747
1822
  }
1748
1823
  _connectSingle() {
1749
- const input = this.querySelector(":scope > input:not([type=hidden])");
1824
+ const input = this.querySelector(":scope > input:not([type=hidden]):not([type=time]):not([data-time-start]):not([data-time-end])");
1750
1825
  if (!(input instanceof HTMLInputElement)) {
1751
1826
  console.warn("<date-picker> expects a direct child text input");
1752
1827
  return;
1753
1828
  }
1754
1829
  this._connected = true;
1755
1830
  this._input = input;
1831
+ this._discoverTimeFields();
1756
1832
  this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
1757
1833
  this._field.onAttributesChanged = () => this._syncInputState();
1758
1834
  this._field.confirm = (date) => this._confirmDate(date);
@@ -1775,7 +1851,31 @@
1775
1851
  input.defaultValue = input.value;
1776
1852
  }
1777
1853
  }
1854
+ _discoverTimeFields() {
1855
+ this._timeFields = [null, null];
1856
+ const markers = ["data-time-start", "data-time-end"];
1857
+ for (const [index, marker] of markers.entries()) {
1858
+ const matches = this.querySelectorAll(`:scope > input[${marker}]`);
1859
+ if (matches.length > 1) {
1860
+ console.warn(`<date-picker> expects at most one direct child input[${marker}]; ignoring extras`);
1861
+ }
1862
+ const candidate = matches[0];
1863
+ if (candidate == null)
1864
+ continue;
1865
+ if (!(candidate instanceof HTMLInputElement) || candidate.type !== "time") {
1866
+ console.warn(`<date-picker> input[${marker}] must be an <input type="time">; ignoring it`);
1867
+ continue;
1868
+ }
1869
+ this._timeFields[index] = candidate;
1870
+ }
1871
+ if (this.querySelector(":scope > input[type=time]:not([data-time-start]):not([data-time-end])")) {
1872
+ console.warn("<date-picker> time inputs need [data-time-start] or [data-time-end]; ignoring it");
1873
+ }
1874
+ }
1778
1875
  _connectRange() {
1876
+ if (this.querySelector(":scope > input[data-time-start], :scope > input[data-time-end]")) {
1877
+ console.warn("<date-picker range> time companions are not supported yet; leaving them native");
1878
+ }
1779
1879
  const startInput = this.querySelector(":scope > input[data-range-start]");
1780
1880
  const endInput = this.querySelector(":scope > input[data-range-end]");
1781
1881
  if (!(startInput instanceof HTMLInputElement) || !(endInput instanceof HTMLInputElement)) {
@@ -1827,6 +1927,8 @@
1827
1927
  this.hide(false);
1828
1928
  this._controller?.abort();
1829
1929
  this._controller = null;
1930
+ this._clearTimeOrderValidity();
1931
+ this._timeFields = [null, null];
1830
1932
  if (this._rangeMode()) {
1831
1933
  for (const field of this._fields || [])
1832
1934
  this._teardownField(field);
@@ -1911,6 +2013,15 @@
1911
2013
  else
1912
2014
  this.removeAttribute("locale");
1913
2015
  }
2016
+ get monthFormat() {
2017
+ return this.getAttribute("month-format") === "short" ? "short" : "long";
2018
+ }
2019
+ set monthFormat(value) {
2020
+ if (value === "short")
2021
+ this.setAttribute("month-format", "short");
2022
+ else
2023
+ this.removeAttribute("month-format");
2024
+ }
1914
2025
  get min() {
1915
2026
  const value = this.getAttribute("min") || "";
1916
2027
  return isDate(value) ? value : "";
@@ -2107,6 +2218,13 @@
2107
2218
  input.addEventListener("keydown", (event) => {
2108
2219
  this._onFieldKeyDown("", event);
2109
2220
  }, { signal });
2221
+ for (const [index, timeInput] of this._timeFields.entries()) {
2222
+ if (!timeInput)
2223
+ continue;
2224
+ const endpoint = index === 1 ? "end" : "start";
2225
+ timeInput.addEventListener("input", () => this._revalidateTimeOrder(endpoint), { signal });
2226
+ timeInput.addEventListener("change", () => this._revalidateTimeOrder(endpoint), { signal });
2227
+ }
2110
2228
  button.addEventListener("click", (event) => {
2111
2229
  if (this._open) {
2112
2230
  if (event.detail === 0) {
@@ -2299,6 +2417,7 @@
2299
2417
  calendar.locale = this.locale;
2300
2418
  calendar.min = this.min;
2301
2419
  calendar.max = this.max;
2420
+ calendar.monthFormat = this.monthFormat;
2302
2421
  calendar.messages = this._messages;
2303
2422
  calendar.source = this._source;
2304
2423
  calendar.dateState = this._dateState;
@@ -2524,6 +2643,33 @@
2524
2643
  }
2525
2644
  }
2526
2645
  }
2646
+ _revalidateTimeOrder(which) {
2647
+ const [startInput, endInput] = this._timeFields;
2648
+ const bounds = ["start", "end"];
2649
+ const inputs = [startInput, endInput];
2650
+ const startValue = startInput && !startInput.disabled ? startInput.value.trim() : "";
2651
+ const endValue = endInput && !endInput.disabled ? endInput.value.trim() : "";
2652
+ const inverted = Boolean(startInput && endInput && startValue && endValue) && isTime(startValue) && isTime(endValue) && compareTimes(endValue, startValue) < 0;
2653
+ for (const [index, bound] of bounds.entries()) {
2654
+ const input = inputs[index];
2655
+ if (!input)
2656
+ continue;
2657
+ const message = bound === "end" ? this._messages.rangeOrderEnd : this._messages.rangeOrderStart;
2658
+ const shouldHaveOrder = inverted && bound === which;
2659
+ if (shouldHaveOrder) {
2660
+ input.setCustomValidity(message);
2661
+ this._timeOrderOwned.add(input);
2662
+ } else if (this._timeOrderOwned.has(input)) {
2663
+ input.setCustomValidity("");
2664
+ this._timeOrderOwned.delete(input);
2665
+ }
2666
+ }
2667
+ }
2668
+ _clearTimeOrderValidity() {
2669
+ for (const input of this._timeOrderOwned)
2670
+ input.setCustomValidity("");
2671
+ this._timeOrderOwned.clear();
2672
+ }
2527
2673
  _restoreDefault() {
2528
2674
  const field = this._field;
2529
2675
  if (!field)
@@ -2608,10 +2754,13 @@
2608
2754
  return true;
2609
2755
  if (!input.value.trim()) {
2610
2756
  input.setCustomValidity("");
2611
- return input.checkValidity();
2757
+ } else {
2758
+ await this._commitText(false, { force: true });
2612
2759
  }
2613
- await this._commitText(false, { force: true });
2614
- return input.checkValidity();
2760
+ this._revalidateTimeOrder("start");
2761
+ this._revalidateTimeOrder("end");
2762
+ const timesValid = this._timeFields.every((timeInput) => timeInput?.checkValidity() ?? true);
2763
+ return input.checkValidity() && timesValid;
2615
2764
  }
2616
2765
  show(options = {}) {
2617
2766
  const panel = this._panel;