@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.
- package/README.md +30 -5
- package/custom-elements.json +44 -0
- package/dist/date-picker.css +114 -18
- package/dist/date-picker.js +176 -19
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +128 -32
- package/dist/types/calendar-model.d.ts.map +1 -1
- package/dist/types/date-calendar.d.ts +3 -0
- package/dist/types/date-calendar.d.ts.map +1 -1
- package/dist/types/date-picker.d.ts +24 -0
- package/dist/types/date-picker.d.ts.map +1 -1
- package/dist/types/date.d.ts +6 -0
- package/dist/types/date.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/intl.d.ts +9 -0
- package/dist/types/intl.d.ts.map +1 -1
- package/dist/types/time.d.ts +8 -0
- package/dist/types/time.d.ts.map +1 -0
- package/docs/API.md +52 -2
- package/docs/CALENDAR-INTEGRATION.md +12 -6
- package/docs/DECISIONS.md +8 -1
- package/docs/ROADMAP.md +3 -2
- package/docs/USE_CASES.md +14 -1
- package/package.json +2 -2
- package/src/calendar-model.js +2 -1
- package/src/date-calendar.js +44 -8
- package/src/date-picker.css +114 -18
- package/src/date-picker.js +108 -5
- package/src/date.js +13 -3
- package/src/index.js +1 -0
- package/src/intl.js +38 -2
- package/src/time.js +39 -0
package/src/date-picker.css
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
109
|
-
|
|
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:
|
|
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:
|
|
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 {
|
package/src/date-picker.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
1152
|
+
} else {
|
|
1153
|
+
await this._commitText(false, { force: true });
|
|
1055
1154
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
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
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() + ((
|
|
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
|
+
}
|