@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 +27 -4
- package/custom-elements.json +44 -0
- package/dist/date-picker.css +111 -12
- package/dist/date-picker.js +162 -13
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +125 -26
- 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/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 +47 -0
- 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/date-calendar.js +42 -7
- package/src/date-picker.css +111 -12
- package/src/date-picker.js +108 -5
- package/src/index.js +1 -0
- package/src/intl.js +35 -0
- package/src/time.js +39 -0
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/index.js
CHANGED
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
|
+
}
|