@lekoala/date-picker 0.5.0 → 0.5.2
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 +296 -281
- package/custom-elements.json +45 -2
- package/dist/date-picker.js +300 -107
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +11 -11
- package/dist/date-picker.standalone.min.js +13 -506
- package/dist/types/activation-intents.d.ts +10 -0
- package/dist/types/activation-intents.d.ts.map +1 -0
- package/dist/types/control-classes.d.ts +11 -1
- package/dist/types/control-classes.d.ts.map +1 -1
- package/dist/types/date-calendar.d.ts +39 -51
- package/dist/types/date-calendar.d.ts.map +1 -1
- package/dist/types/date-field.d.ts +14 -0
- package/dist/types/date-field.d.ts.map +1 -1
- package/dist/types/date-picker.d.ts +71 -81
- package/dist/types/date-picker.d.ts.map +1 -1
- package/dist/types/messages.d.ts +2 -0
- package/dist/types/messages.d.ts.map +1 -1
- package/dist/types/upgrade-properties.d.ts +8 -0
- package/dist/types/upgrade-properties.d.ts.map +1 -0
- package/docs/API.md +442 -430
- package/docs/DECISIONS.md +74 -68
- package/docs/USE_CASES.md +156 -156
- package/package.json +2 -2
- package/src/activation-intents.js +9 -0
- package/src/control-classes.js +36 -6
- package/src/date-calendar.js +127 -35
- package/src/date-field.js +32 -1
- package/src/date-picker.js +202 -101
- package/src/messages.js +12 -0
- package/src/upgrade-properties.js +14 -0
package/src/date-picker.js
CHANGED
|
@@ -1,21 +1,52 @@
|
|
|
1
1
|
import { autoUpdate, reposition } from "@lekoala/floating";
|
|
2
|
-
import {
|
|
2
|
+
import { activationIntents } from "./activation-intents.js";
|
|
3
|
+
import {
|
|
4
|
+
getDefaultControlClasses,
|
|
5
|
+
normalizeControlClasses,
|
|
6
|
+
resolveControlClasses,
|
|
7
|
+
setDefaultControlClasses,
|
|
8
|
+
} from "./control-classes.js";
|
|
3
9
|
import { compareDates, isDate, monthKey, todayISO } from "./date.js";
|
|
4
10
|
import { DateCalendarElement } from "./date-calendar.js";
|
|
5
|
-
import { DateFieldController } from "./date-field.js";
|
|
11
|
+
import { DateFieldController, isEditable } from "./date-field.js";
|
|
6
12
|
import { DateRangeController, normalizeRange } from "./date-range.js";
|
|
7
13
|
import { createDateAdapter, formatLongDate, resolveLocale } from "./intl.js";
|
|
8
|
-
import { getDefaultMessages } from "./messages.js";
|
|
14
|
+
import { getDefaultMessages, setDefaultMessages, subscribeDefaultMessages } from "./messages.js";
|
|
9
15
|
import { compareTimes, isTime } from "./time.js";
|
|
16
|
+
import { upgradeProperties } from "./upgrade-properties.js";
|
|
10
17
|
|
|
11
18
|
let uid = 0;
|
|
12
19
|
|
|
13
20
|
/** Pointer travel, in CSS pixels, that turns a press into an endpoint drag. */
|
|
14
21
|
const DRAG_THRESHOLD = 4;
|
|
15
22
|
|
|
23
|
+
/** A grid activation intent: the generation at interaction time plus the bound
|
|
24
|
+
* a drag drop targets, empty for a normal pick.
|
|
25
|
+
* @typedef {{id: number, endpoint: "" | "start" | "end"}} PickIntent */
|
|
26
|
+
|
|
16
27
|
export class DatePickerElement extends HTMLElement {
|
|
17
28
|
static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
|
|
18
29
|
|
|
30
|
+
/** @public @param {Partial<import("./control-classes.js").ControlClasses>} classes */
|
|
31
|
+
static setDefaultControlClasses(classes) {
|
|
32
|
+
setDefaultControlClasses(classes);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @public */
|
|
36
|
+
static getDefaultControlClasses() {
|
|
37
|
+
return getDefaultControlClasses();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @public @param {Partial<typeof import("./messages.js").DEFAULT_MESSAGES>} messages */
|
|
41
|
+
static setDefaultMessages(messages) {
|
|
42
|
+
setDefaultMessages(messages);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @public */
|
|
46
|
+
static getDefaultMessages() {
|
|
47
|
+
return getDefaultMessages();
|
|
48
|
+
}
|
|
49
|
+
|
|
19
50
|
constructor() {
|
|
20
51
|
super();
|
|
21
52
|
this._id = `date-picker-${++uid}`;
|
|
@@ -36,12 +67,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
36
67
|
this._timeOrderOwned = new Set();
|
|
37
68
|
/** @type {"" | "start" | "end"} */
|
|
38
69
|
this._lastFocusEndpoint = "";
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
* @type {Map<string, {id: number, endpoint: "" | "start" | "end"}>} */
|
|
44
|
-
this._pendingIntents = new Map();
|
|
70
|
+
/** Activation generation. A grid intent records it at interaction time
|
|
71
|
+
* and travels with its own activation (see `activationIntents`), so a late
|
|
72
|
+
* response is checked against the generation, never looked up by date. */
|
|
73
|
+
this._activationId = 0;
|
|
45
74
|
/** Candidate date currently projected onto the calendar band, if any. */
|
|
46
75
|
this._previewDate = "";
|
|
47
76
|
/** Live endpoint drag, or null. @type {{pointerId:number, endpoint:"start"|"end", x:number, y:number, date:string, active:boolean} | null} */
|
|
@@ -63,15 +92,19 @@ export class DatePickerElement extends HTMLElement {
|
|
|
63
92
|
this._stopTracking = null;
|
|
64
93
|
this._open = false;
|
|
65
94
|
this._suppressFocusOpen = false;
|
|
66
|
-
|
|
67
|
-
this.
|
|
68
|
-
/** @type {
|
|
95
|
+
/** @type {typeof import("./messages.js").DEFAULT_MESSAGES | null} */
|
|
96
|
+
this._messages = null;
|
|
97
|
+
/** @type {Partial<import("./control-classes.js").ControlClasses> | null} */
|
|
98
|
+
this._controlClasses = null;
|
|
99
|
+
/** @type {(() => void) | null} */
|
|
100
|
+
this._stopDefaultMessages = null;
|
|
101
|
+
/** @type {import("./date-calendar.js").DateSource | null} */
|
|
69
102
|
this._source = null;
|
|
70
|
-
/** @type {
|
|
103
|
+
/** @type {import("./date-calendar.js").DateStateResolver | null} */
|
|
71
104
|
this._dateState = null;
|
|
72
|
-
/** @type {
|
|
105
|
+
/** @type {import("./date-calendar.js").DayRenderer | null} */
|
|
73
106
|
this._renderDay = null;
|
|
74
|
-
/** @type {
|
|
107
|
+
/** @type {import("./date-calendar.js").DateDisabledPredicate | null} */
|
|
75
108
|
this._isDateDisabled = null;
|
|
76
109
|
/** Public picker coordinate-space request. @type {"viewport" | "document"} */
|
|
77
110
|
this._coordinateSpace = "viewport";
|
|
@@ -83,11 +116,32 @@ export class DatePickerElement extends HTMLElement {
|
|
|
83
116
|
|
|
84
117
|
connectedCallback() {
|
|
85
118
|
if (this._connected) return;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
119
|
+
upgradeProperties(this, [
|
|
120
|
+
"locale",
|
|
121
|
+
"min",
|
|
122
|
+
"max",
|
|
123
|
+
"monthFormat",
|
|
124
|
+
"openOnFocus",
|
|
125
|
+
"coordinateSpace",
|
|
126
|
+
"controlClasses",
|
|
127
|
+
"messages",
|
|
128
|
+
"source",
|
|
129
|
+
"dateState",
|
|
130
|
+
"renderDay",
|
|
131
|
+
"isDateDisabled",
|
|
132
|
+
]);
|
|
133
|
+
if (this.hasAttribute("range")) this._connectRange();
|
|
134
|
+
else this._connectSingle();
|
|
135
|
+
// The range setter requires the controller created by _connectRange().
|
|
136
|
+
if (this._connected) {
|
|
137
|
+
upgradeProperties(this, ["value", "range"]);
|
|
138
|
+
this._stopDefaultMessages = subscribeDefaultMessages(() => {
|
|
139
|
+
if (this._messages === null) this._refreshMessages();
|
|
140
|
+
});
|
|
141
|
+
// Applying the initial value clears custom validity; check it against
|
|
142
|
+
// min/max/isDateDisabled/source like any later constraint change.
|
|
143
|
+
void this.validate();
|
|
89
144
|
}
|
|
90
|
-
this._connectSingle();
|
|
91
145
|
}
|
|
92
146
|
|
|
93
147
|
_connectSingle() {
|
|
@@ -101,7 +155,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
101
155
|
this._connected = true;
|
|
102
156
|
this._input = input;
|
|
103
157
|
this._discoverTimeFields();
|
|
104
|
-
this._field = new DateFieldController(input, { messages: this.
|
|
158
|
+
this._field = new DateFieldController(input, { messages: this.messages, locale: this.locale });
|
|
105
159
|
this._field.onAttributesChanged = () => this._syncInputState();
|
|
106
160
|
this._field.confirm = (date, options) => this._confirmDate(date, options);
|
|
107
161
|
this._setupFormValue();
|
|
@@ -115,18 +169,14 @@ export class DatePickerElement extends HTMLElement {
|
|
|
115
169
|
this.getAttribute("value") || (isDate(input.value) ? input.value : adapter.parse(input.value));
|
|
116
170
|
if (initial && isDate(initial)) this._setValue(initial, { emit: false, format: true });
|
|
117
171
|
else if (!input.value) this._setValue("", { emit: false, format: false });
|
|
118
|
-
else input.setCustomValidity(this.
|
|
172
|
+
else input.setCustomValidity(this.messages.invalidDate);
|
|
119
173
|
|
|
120
174
|
// Native form reset restores control defaultValues, then _restoreDefault()
|
|
121
|
-
// recommits the canonical value from them.
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// text (picker value attr, no input value attr) does that text become the
|
|
125
|
-
// default to restore.
|
|
175
|
+
// recommits the canonical value from them. Only when enhancement itself
|
|
176
|
+
// supplies the initial text (picker value attr, no input value attr) does
|
|
177
|
+
// that text become the default to restore.
|
|
126
178
|
if (this._field.hidden) this._field.hidden.defaultValue = this._value;
|
|
127
|
-
|
|
128
|
-
input.defaultValue = input.value;
|
|
129
|
-
}
|
|
179
|
+
this._field.adoptDefault();
|
|
130
180
|
}
|
|
131
181
|
|
|
132
182
|
/**
|
|
@@ -171,8 +221,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
171
221
|
this._connected = true;
|
|
172
222
|
this._input = null;
|
|
173
223
|
this._fields = [
|
|
174
|
-
new DateFieldController(startInput, { messages: this.
|
|
175
|
-
new DateFieldController(endInput, { messages: this.
|
|
224
|
+
new DateFieldController(startInput, { messages: this.messages, locale: this.locale }),
|
|
225
|
+
new DateFieldController(endInput, { messages: this.messages, locale: this.locale }),
|
|
176
226
|
];
|
|
177
227
|
this._range = new DateRangeController();
|
|
178
228
|
this._rangeOriginalDescribedBy = [
|
|
@@ -193,22 +243,27 @@ export class DatePickerElement extends HTMLElement {
|
|
|
193
243
|
const adapter = this._adapter();
|
|
194
244
|
const initialStart = isDate(startInput.value) ? startInput.value : adapter.parse(startInput.value);
|
|
195
245
|
const initialEnd = isDate(endInput.value) ? endInput.value : adapter.parse(endInput.value);
|
|
246
|
+
// Read the authored text before _setRange() reformats an unparsable one away.
|
|
247
|
+
const startText = startInput.value;
|
|
248
|
+
const endText = endInput.value;
|
|
196
249
|
this._setRange(initialStart || "", initialEnd || "", { emit: false });
|
|
197
|
-
|
|
198
|
-
startInput
|
|
199
|
-
|
|
250
|
+
for (const [input, text, initial] of /** @type {const} */ ([
|
|
251
|
+
[startInput, startText, initialStart],
|
|
252
|
+
[endInput, endText, initialEnd],
|
|
253
|
+
])) {
|
|
254
|
+
if (!text.trim() || isDate(initial)) continue;
|
|
255
|
+
input.value = text;
|
|
256
|
+
input.setCustomValidity(this.messages.invalidDate);
|
|
257
|
+
}
|
|
200
258
|
|
|
201
259
|
if (this._fields[0].hidden) this._fields[0].hidden.defaultValue = this._range.start;
|
|
202
260
|
if (this._fields[1].hidden) this._fields[1].hidden.defaultValue = this._range.end;
|
|
203
|
-
|
|
204
|
-
startInput.defaultValue = startInput.value;
|
|
205
|
-
}
|
|
206
|
-
if (endInput.getAttribute("value") == null && endInput.defaultValue === "") {
|
|
207
|
-
endInput.defaultValue = endInput.value;
|
|
208
|
-
}
|
|
261
|
+
for (const field of this._fields) field.adoptDefault();
|
|
209
262
|
}
|
|
210
263
|
|
|
211
264
|
disconnectedCallback() {
|
|
265
|
+
this._stopDefaultMessages?.();
|
|
266
|
+
this._stopDefaultMessages = null;
|
|
212
267
|
this._connected = false;
|
|
213
268
|
this.hide(false);
|
|
214
269
|
this._controller?.abort();
|
|
@@ -254,8 +309,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
254
309
|
if (!this._connected || this._reflecting || oldValue === newValue) return;
|
|
255
310
|
if (name === "value") {
|
|
256
311
|
if (this._rangeMode()) return;
|
|
257
|
-
if (!newValue || isDate(newValue))
|
|
312
|
+
if (!newValue || isDate(newValue)) {
|
|
313
|
+
this._supersedeActivations();
|
|
258
314
|
this._setValue(newValue || "", { emit: false, format: true, reflect: false });
|
|
315
|
+
}
|
|
259
316
|
return;
|
|
260
317
|
}
|
|
261
318
|
if (name === "locale") this._refreshLocale();
|
|
@@ -284,6 +341,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
284
341
|
throw new TypeError("value is not available in range mode; use range");
|
|
285
342
|
}
|
|
286
343
|
if (value && !isDate(value)) throw new TypeError(`Invalid date-picker value: ${value}`);
|
|
344
|
+
this._supersedeActivations();
|
|
287
345
|
this._setValue(value || "", { emit: false, format: true });
|
|
288
346
|
}
|
|
289
347
|
|
|
@@ -295,6 +353,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
295
353
|
set range(value) {
|
|
296
354
|
if (!this._rangeMode()) throw new TypeError("range is only available on <date-picker range>");
|
|
297
355
|
const { start, end } = normalizeRange(value);
|
|
356
|
+
this._supersedeActivations();
|
|
298
357
|
this._setRange(start, end);
|
|
299
358
|
}
|
|
300
359
|
|
|
@@ -356,37 +415,45 @@ export class DatePickerElement extends HTMLElement {
|
|
|
356
415
|
}
|
|
357
416
|
|
|
358
417
|
/**
|
|
359
|
-
* Author classes for header controls;
|
|
418
|
+
* Author classes for header controls; omitted roles follow current global defaults.
|
|
360
419
|
* @public
|
|
361
420
|
* @returns {import("./control-classes.js").ControlClasses}
|
|
362
421
|
*/
|
|
363
422
|
get controlClasses() {
|
|
364
|
-
return
|
|
423
|
+
return resolveControlClasses(this._controlClasses);
|
|
365
424
|
}
|
|
366
425
|
|
|
367
426
|
/** @param {Partial<import("./control-classes.js").ControlClasses> | null} value */
|
|
368
427
|
set controlClasses(value) {
|
|
369
|
-
|
|
370
|
-
if (sameControlClasses(classes, this._controlClasses)) return;
|
|
371
|
-
this._controlClasses = classes;
|
|
428
|
+
this._controlClasses = normalizeControlClasses(value);
|
|
372
429
|
if (this._calendar) this._calendar.controlClasses = this._controlClasses;
|
|
373
430
|
}
|
|
374
431
|
|
|
375
|
-
/** @public */
|
|
432
|
+
/** @public @returns {typeof import("./messages.js").DEFAULT_MESSAGES} */
|
|
376
433
|
get messages() {
|
|
377
|
-
return this._messages;
|
|
434
|
+
return this._messages ?? getDefaultMessages();
|
|
378
435
|
}
|
|
379
436
|
|
|
437
|
+
/** @param {Partial<typeof import("./messages.js").DEFAULT_MESSAGES> | null} value */
|
|
380
438
|
set messages(value) {
|
|
381
|
-
this._messages = { ...getDefaultMessages(), ...
|
|
439
|
+
this._messages = value == null ? null : { ...getDefaultMessages(), ...value };
|
|
382
440
|
if (this._calendar) this._calendar.messages = this._messages;
|
|
383
|
-
|
|
384
|
-
|
|
441
|
+
this._refreshMessages();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
_refreshMessages() {
|
|
445
|
+
const messages = this.messages;
|
|
446
|
+
for (const field of this._fields || []) field.setMessages(messages);
|
|
447
|
+
this._field?.setMessages(messages);
|
|
448
|
+
this._panel?.setAttribute("aria-label", messages.calendar);
|
|
449
|
+
const field = this._field ?? this._fields?.[0];
|
|
450
|
+
if (this._formatHint && field)
|
|
451
|
+
this._formatHint.textContent = `${messages.formatHint}: ${field.placeholder}`;
|
|
385
452
|
this._refreshButtonLabel();
|
|
386
|
-
void this.validate();
|
|
453
|
+
if (this._connected) void this.validate();
|
|
387
454
|
}
|
|
388
455
|
|
|
389
|
-
/** @public @returns {
|
|
456
|
+
/** @public @returns {import("./date-calendar.js").DateSource | null} */
|
|
390
457
|
get source() {
|
|
391
458
|
return this._source;
|
|
392
459
|
}
|
|
@@ -397,7 +464,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
397
464
|
if (this._connected) void this.validate();
|
|
398
465
|
}
|
|
399
466
|
|
|
400
|
-
/** @public @returns {
|
|
467
|
+
/** @public @returns {import("./date-calendar.js").DateStateResolver | null} */
|
|
401
468
|
get dateState() {
|
|
402
469
|
return this._dateState;
|
|
403
470
|
}
|
|
@@ -408,7 +475,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
408
475
|
if (this._connected) void this.validate();
|
|
409
476
|
}
|
|
410
477
|
|
|
411
|
-
/** @public @returns {
|
|
478
|
+
/** @public @returns {import("./date-calendar.js").DayRenderer | null} */
|
|
412
479
|
get renderDay() {
|
|
413
480
|
return this._renderDay;
|
|
414
481
|
}
|
|
@@ -418,7 +485,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
418
485
|
if (this._calendar) this._calendar.renderDay = this._renderDay;
|
|
419
486
|
}
|
|
420
487
|
|
|
421
|
-
/** @public @returns {
|
|
488
|
+
/** @public @returns {import("./date-calendar.js").DateDisabledPredicate | null} */
|
|
422
489
|
get isDateDisabled() {
|
|
423
490
|
return this._isDateDisabled;
|
|
424
491
|
}
|
|
@@ -453,8 +520,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
453
520
|
// movement lives in the field controller.
|
|
454
521
|
this._field?.syncInputState();
|
|
455
522
|
const button = this._buttons[0]?.button;
|
|
523
|
+
// Own state only: a disabled ancestor fieldset already disables the button
|
|
524
|
+
// natively, and copying it would stick once the fieldset is re-enabled.
|
|
456
525
|
if (button) button.disabled = input.disabled || input.readOnly;
|
|
457
|
-
if (this._open && (input
|
|
526
|
+
if (this._open && !isEditable(input)) this.hide(false);
|
|
458
527
|
}
|
|
459
528
|
|
|
460
529
|
_syncRangeFields() {
|
|
@@ -469,9 +538,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
469
538
|
}
|
|
470
539
|
this._syncDragAffordance();
|
|
471
540
|
const active = this._range?.activeEndpoint === "end" ? 1 : 0;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
541
|
+
const activeInput = this._fields[active]?.input;
|
|
542
|
+
if (this._open && activeInput && !isEditable(activeInput)) this.hide(false);
|
|
475
543
|
}
|
|
476
544
|
|
|
477
545
|
_setRangeAria() {
|
|
@@ -541,7 +609,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
541
609
|
panel.className = "dp-picker-panel";
|
|
542
610
|
panel.setAttribute("popover", "manual");
|
|
543
611
|
panel.setAttribute("role", "dialog");
|
|
544
|
-
panel.setAttribute("aria-label", this.
|
|
612
|
+
panel.setAttribute("aria-label", this.messages.calendar);
|
|
545
613
|
|
|
546
614
|
const calendar = new DateCalendarElement();
|
|
547
615
|
calendar.selection = "none";
|
|
@@ -663,17 +731,27 @@ export class DatePickerElement extends HTMLElement {
|
|
|
663
731
|
{ signal },
|
|
664
732
|
);
|
|
665
733
|
button.addEventListener("keydown", (event) => this._onTriggerKeyDown("", event), { signal });
|
|
734
|
+
calendar.addEventListener("click", (event) => this._captureGridIntent(event), { capture: true, signal });
|
|
735
|
+
calendar.addEventListener("keydown", (event) => this._captureGridIntent(event), {
|
|
736
|
+
capture: true,
|
|
737
|
+
signal,
|
|
738
|
+
});
|
|
666
739
|
calendar.addEventListener(
|
|
667
740
|
"dateactivate",
|
|
668
741
|
(event) => {
|
|
669
742
|
const custom = /** @type {CustomEvent} */ (event);
|
|
670
743
|
const date = custom.detail?.date;
|
|
671
744
|
if (!isDate(date)) return;
|
|
745
|
+
const intent = /** @type {PickIntent | undefined} */ (activationIntents.get(custom));
|
|
672
746
|
// The app listener on <date-picker> runs after this during bubbling;
|
|
673
747
|
// defer the commit so preventDefault() is actually respected.
|
|
674
748
|
queueMicrotask(() => {
|
|
675
749
|
if (custom.defaultPrevented || !this._connected) return;
|
|
676
|
-
|
|
750
|
+
// A grid pick that resolves after the popup closed, after a newer pick
|
|
751
|
+
// or after a programmatic value must not overwrite it. A programmatic
|
|
752
|
+
// calendar.activateDate() carries no intent and still commits.
|
|
753
|
+
if (intent && intent.id !== this._activationId) return;
|
|
754
|
+
if (!this._input || !isEditable(this._input)) return;
|
|
677
755
|
this._field?.dirty();
|
|
678
756
|
this._setValue(date, { emit: true, format: true });
|
|
679
757
|
this.hide(false);
|
|
@@ -697,7 +775,16 @@ export class DatePickerElement extends HTMLElement {
|
|
|
697
775
|
|
|
698
776
|
for (const [index, field] of this._fields.entries()) {
|
|
699
777
|
const endpoint = index === 1 ? "end" : "start";
|
|
700
|
-
field.input.addEventListener(
|
|
778
|
+
field.input.addEventListener(
|
|
779
|
+
"input",
|
|
780
|
+
() => {
|
|
781
|
+
// handleInput() rewrites the custom validity: any order error is gone.
|
|
782
|
+
field.handleInput();
|
|
783
|
+
this._orderTags.delete(index);
|
|
784
|
+
if (!field.isDirty) this._revalidateRange(endpoint);
|
|
785
|
+
},
|
|
786
|
+
{ signal },
|
|
787
|
+
);
|
|
701
788
|
field.input.addEventListener("change", () => void this._commitFieldText(endpoint), { signal });
|
|
702
789
|
field.input.addEventListener("blur", () => void this._commitFieldText(endpoint), { signal });
|
|
703
790
|
field.input.addEventListener(
|
|
@@ -748,11 +835,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
748
835
|
if (!isDate(date)) return;
|
|
749
836
|
queueMicrotask(() => {
|
|
750
837
|
if (custom.defaultPrevented || !this._connected || !this._range) return;
|
|
751
|
-
const intent =
|
|
838
|
+
const intent = /** @type {PickIntent | undefined} */ (activationIntents.get(custom));
|
|
752
839
|
// A response is dropped when the active endpoint changed, the popup
|
|
753
840
|
// closed or a newer activation superseded this one.
|
|
754
|
-
if (intent === undefined || intent.id !== this.
|
|
755
|
-
this._pendingIntents.delete(date);
|
|
841
|
+
if (intent === undefined || intent.id !== this._activationId) return;
|
|
756
842
|
const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
|
|
757
843
|
// Click, keyboard and drop share this one machine; only the entry
|
|
758
844
|
// point differs, a drag naming the handle it grabbed.
|
|
@@ -805,8 +891,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
805
891
|
const calendar = this._calendar;
|
|
806
892
|
if (!this._range || !calendar) return;
|
|
807
893
|
this._range.focus(endpoint);
|
|
808
|
-
this.
|
|
809
|
-
this._pendingIntents.clear();
|
|
894
|
+
this._activationId++;
|
|
810
895
|
this._lastFocusEndpoint = endpoint;
|
|
811
896
|
const target = this._boundCanonical(endpoint) || todayISO();
|
|
812
897
|
calendar.display = monthKey(target);
|
|
@@ -892,8 +977,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
892
977
|
if (switched) {
|
|
893
978
|
// A late calendar activation targeting the previous endpoint must not
|
|
894
979
|
// commit after the intent moved to the other bound.
|
|
895
|
-
this.
|
|
896
|
-
this._pendingIntents.clear();
|
|
980
|
+
this._activationId++;
|
|
897
981
|
}
|
|
898
982
|
if (!this.openOnFocus || this._suppressFocusOpen) return;
|
|
899
983
|
const related = event.relatedTarget;
|
|
@@ -901,30 +985,49 @@ export class DatePickerElement extends HTMLElement {
|
|
|
901
985
|
if (!this._open) this.show({ moveFocus: false });
|
|
902
986
|
}
|
|
903
987
|
|
|
904
|
-
/** Record
|
|
988
|
+
/** Record grid activation intent (click or keyboard) so a stale,
|
|
905
989
|
* late-confirmed dateactivate can be recognized and dropped. Each new grid
|
|
906
990
|
* activation supersedes every earlier pending one.
|
|
907
991
|
* @param {Event} event */
|
|
908
992
|
_captureGridIntent(event) {
|
|
909
|
-
if (
|
|
993
|
+
if (this._rangeMode() ? !this._range : !this._field) return;
|
|
910
994
|
if (event.type === "keydown") {
|
|
911
995
|
const key = /** @type {KeyboardEvent} */ (event).key;
|
|
912
996
|
if (key !== "Enter" && key !== " ") return;
|
|
913
997
|
}
|
|
998
|
+
// The click tail of a drag never activates: it must not supersede the drop.
|
|
999
|
+
if (event.type === "click" && this._suppressGridClick) return;
|
|
914
1000
|
if (!(event.target instanceof Element)) return;
|
|
915
1001
|
const cell = event.target.closest(".dp-day[data-date]");
|
|
916
1002
|
const date = cell?.getAttribute("data-date") || "";
|
|
917
1003
|
if (!isDate(date)) return;
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1004
|
+
this._attachIntent(date, "");
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Hand a new intent to the calendar's next activation of `date`. Taking a
|
|
1009
|
+
* new generation supersedes every earlier pending activation.
|
|
1010
|
+
* @param {string} date @param {"" | "start" | "end"} endpoint
|
|
1011
|
+
*/
|
|
1012
|
+
_attachIntent(date, endpoint) {
|
|
1013
|
+
if (!this._calendar) return;
|
|
1014
|
+
this._calendar._hostIntent = { date, intent: { id: ++this._activationId, endpoint } };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Invalidate every pending grid activation: each one carries its own intent,
|
|
1019
|
+
* which now fails the generation check. A programmatic
|
|
1020
|
+
* `calendar.activateDate()` carries none.
|
|
1021
|
+
*/
|
|
1022
|
+
_supersedeActivations() {
|
|
1023
|
+
this._activationId++;
|
|
921
1024
|
}
|
|
922
1025
|
|
|
923
1026
|
/** Whether one bound can receive a user pick right now.
|
|
924
1027
|
* @param {"start" | "end"} bound */
|
|
925
1028
|
_boundEditable(bound) {
|
|
926
1029
|
const field = this._fields?.[bound === "end" ? 1 : 0];
|
|
927
|
-
return field != null &&
|
|
1030
|
+
return field != null && isEditable(field.input);
|
|
928
1031
|
}
|
|
929
1032
|
|
|
930
1033
|
/**
|
|
@@ -1122,8 +1225,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1122
1225
|
// guard: a late response can never move a bound after the popup closed or
|
|
1123
1226
|
// after a newer interaction.
|
|
1124
1227
|
const target = drag.endpoint === "start" ? transition.range.start : transition.range.end;
|
|
1125
|
-
this.
|
|
1126
|
-
this._pendingIntents.set(target, { id: ++this._rangeCommitId, endpoint: drag.endpoint });
|
|
1228
|
+
this._attachIntent(target, drag.endpoint);
|
|
1127
1229
|
void this._calendar?.activateDate(target);
|
|
1128
1230
|
}
|
|
1129
1231
|
|
|
@@ -1184,7 +1286,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1184
1286
|
calendar.renderDay = this._renderDay;
|
|
1185
1287
|
calendar.isDateDisabled = this._isDateDisabled;
|
|
1186
1288
|
if (!this._rangeMode() && this._value) calendar.value = this._value;
|
|
1187
|
-
if (panel) panel.setAttribute("aria-label", this.
|
|
1289
|
+
if (panel) panel.setAttribute("aria-label", this.messages.calendar);
|
|
1188
1290
|
}
|
|
1189
1291
|
|
|
1190
1292
|
_refreshLocale() {
|
|
@@ -1193,7 +1295,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1193
1295
|
if (this._rangeMode() && this._fields) {
|
|
1194
1296
|
this._fields[0].setLocale(this.locale);
|
|
1195
1297
|
this._fields[1].setLocale(this.locale);
|
|
1196
|
-
hint.textContent = `${this.
|
|
1298
|
+
hint.textContent = `${this.messages.formatHint}: ${this._fields[0].placeholder}`;
|
|
1197
1299
|
for (const [index, field] of this._fields.entries()) {
|
|
1198
1300
|
if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
|
|
1199
1301
|
field.input.placeholder = field.placeholder;
|
|
@@ -1208,7 +1310,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1208
1310
|
const field = this._field;
|
|
1209
1311
|
if (!field) return;
|
|
1210
1312
|
field.setLocale(this.locale);
|
|
1211
|
-
hint.textContent = `${this.
|
|
1313
|
+
hint.textContent = `${this.messages.formatHint}: ${field.placeholder}`;
|
|
1212
1314
|
if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholder) {
|
|
1213
1315
|
field.input.placeholder = field.placeholder;
|
|
1214
1316
|
this._generatedPlaceholder = true;
|
|
@@ -1224,7 +1326,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1224
1326
|
this._refreshRangeButtonLabel();
|
|
1225
1327
|
return;
|
|
1226
1328
|
}
|
|
1227
|
-
const prefix = this._value ? this.
|
|
1329
|
+
const prefix = this._value ? this.messages.changeDate : this.messages.chooseDate;
|
|
1228
1330
|
const suffix = this._value ? `, ${formatLongDate(this._value, this.locale)}` : "";
|
|
1229
1331
|
single.setAttribute("aria-label", `${prefix}${suffix}`);
|
|
1230
1332
|
}
|
|
@@ -1236,9 +1338,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1236
1338
|
const { start, end } = this._range;
|
|
1237
1339
|
for (const { button, endpoint } of this._buttons) {
|
|
1238
1340
|
const bound = endpoint === "end" ? end : start;
|
|
1239
|
-
const prefix = bound ? this.
|
|
1341
|
+
const prefix = bound ? this.messages.changeDate : this.messages.chooseDate;
|
|
1240
1342
|
const suffix = bound ? `, ${formatLongDate(bound, this.locale)}` : "";
|
|
1241
|
-
const boundLabel = endpoint === "end" ? this.
|
|
1343
|
+
const boundLabel = endpoint === "end" ? this.messages.rangeEnd : this.messages.rangeStart;
|
|
1242
1344
|
button.setAttribute("aria-label", `${prefix}${suffix}, ${boundLabel}`);
|
|
1243
1345
|
}
|
|
1244
1346
|
}
|
|
@@ -1294,7 +1396,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1294
1396
|
}
|
|
1295
1397
|
const confirmed = await calendar.ensureDate(date);
|
|
1296
1398
|
const state = calendar.getDateState(date);
|
|
1297
|
-
if (!confirmed || state.disabled) return { ok: false, message: this.
|
|
1399
|
+
if (!confirmed || state.disabled) return { ok: false, message: this.messages.unavailableDate };
|
|
1298
1400
|
return { ok: true };
|
|
1299
1401
|
}
|
|
1300
1402
|
|
|
@@ -1345,7 +1447,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1345
1447
|
if (!field) return false;
|
|
1346
1448
|
// User initiated picks must respect disabled/readonly bounds; reset,
|
|
1347
1449
|
// explicit validation and programmatic assignments keep their own path.
|
|
1348
|
-
if (options.user === true && (field.input
|
|
1450
|
+
if (options.user === true && !isEditable(field.input)) return false;
|
|
1349
1451
|
const next = value || "";
|
|
1350
1452
|
// The range model may already hold the target (a calendar activation moved
|
|
1351
1453
|
// it), so change detection reads the field canonical, not the model.
|
|
@@ -1425,7 +1527,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1425
1527
|
for (const bound of bounds) {
|
|
1426
1528
|
const index = bound === "end" ? 1 : 0;
|
|
1427
1529
|
const input = this._fields[index].input;
|
|
1428
|
-
const message = bound === "end" ? this.
|
|
1530
|
+
const message = bound === "end" ? this.messages.rangeOrderEnd : this.messages.rangeOrderStart;
|
|
1429
1531
|
const shouldHaveOrder = inverted && bound === which;
|
|
1430
1532
|
if (shouldHaveOrder) {
|
|
1431
1533
|
input.setCustomValidity(message);
|
|
@@ -1451,8 +1553,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1451
1553
|
/** @type {["start", "end"]} */
|
|
1452
1554
|
const bounds = ["start", "end"];
|
|
1453
1555
|
const inputs = [startInput, endInput];
|
|
1454
|
-
const startValue = startInput && !startInput.disabled ? startInput.value.trim() : "";
|
|
1455
|
-
const endValue = endInput && !endInput.disabled ? endInput.value.trim() : "";
|
|
1556
|
+
const startValue = startInput && !startInput.matches(":disabled") ? startInput.value.trim() : "";
|
|
1557
|
+
const endValue = endInput && !endInput.matches(":disabled") ? endInput.value.trim() : "";
|
|
1456
1558
|
const inverted =
|
|
1457
1559
|
Boolean(startInput && endInput && startValue && endValue) &&
|
|
1458
1560
|
isTime(startValue) &&
|
|
@@ -1461,7 +1563,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1461
1563
|
for (const [index, bound] of bounds.entries()) {
|
|
1462
1564
|
const input = inputs[index];
|
|
1463
1565
|
if (!input) continue;
|
|
1464
|
-
const message = bound === "end" ? this.
|
|
1566
|
+
const message = bound === "end" ? this.messages.rangeOrderEnd : this.messages.rangeOrderStart;
|
|
1465
1567
|
const shouldHaveOrder = inverted && bound === which;
|
|
1466
1568
|
if (shouldHaveOrder) {
|
|
1467
1569
|
input.setCustomValidity(message);
|
|
@@ -1542,12 +1644,12 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1542
1644
|
if (!this._fields) return "";
|
|
1543
1645
|
const preferred = this._lastFocusEndpoint || "start";
|
|
1544
1646
|
const preferredIndex = preferred === "end" ? 1 : 0;
|
|
1545
|
-
if (
|
|
1647
|
+
if (isEditable(this._fields[preferredIndex].input)) {
|
|
1546
1648
|
return preferred;
|
|
1547
1649
|
}
|
|
1548
1650
|
const alternative = preferred === "end" ? "start" : "end";
|
|
1549
1651
|
const alternativeIndex = alternative === "end" ? 1 : 0;
|
|
1550
|
-
if (
|
|
1652
|
+
if (isEditable(this._fields[alternativeIndex].input)) {
|
|
1551
1653
|
return alternative;
|
|
1552
1654
|
}
|
|
1553
1655
|
return "";
|
|
@@ -1563,6 +1665,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1563
1665
|
// Empty text flows through commit()'s "clear" outcome, not an early exit.
|
|
1564
1666
|
for (const [index, field] of fields.entries()) {
|
|
1565
1667
|
const result = await field.commit({ moveDisplay: false });
|
|
1668
|
+
// A parse/source error replaced the order error, which no longer owns it.
|
|
1669
|
+
if (result.status === "invalid") this._orderTags.delete(index);
|
|
1566
1670
|
if (result.status === "ok" || result.status === "clear") {
|
|
1567
1671
|
const which = index === 1 ? "end" : "start";
|
|
1568
1672
|
this._setBound(which, result.value || "", { format: result.status === "ok" });
|
|
@@ -1574,11 +1678,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1574
1678
|
}
|
|
1575
1679
|
const input = this._field?.input;
|
|
1576
1680
|
if (!input) return true;
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
await this._commitText(false, { force: true, moveDisplay: false });
|
|
1581
|
-
}
|
|
1681
|
+
// Empty text flows through commit()'s "clear" outcome so the canonical
|
|
1682
|
+
// value and hidden ISO are cleared with it.
|
|
1683
|
+
await this._commitText(false, { force: true, moveDisplay: false });
|
|
1582
1684
|
// Same start-then-end order as the range path, so a from/to inversion
|
|
1583
1685
|
// ends reported on "end". A programmatic time change resyncs here too.
|
|
1584
1686
|
this._revalidateTimeOrder("start");
|
|
@@ -1598,7 +1700,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1598
1700
|
this._activateRangeEndpoint(endpoint);
|
|
1599
1701
|
} else {
|
|
1600
1702
|
const input = this._input;
|
|
1601
|
-
if (!input || input
|
|
1703
|
+
if (!input || !isEditable(input)) return;
|
|
1602
1704
|
const target = this._value || this._adapter().parse(input.value) || todayISO();
|
|
1603
1705
|
calendar.display = monthKey(target);
|
|
1604
1706
|
calendar.focusedDate = target;
|
|
@@ -1646,8 +1748,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1646
1748
|
}
|
|
1647
1749
|
this._open = false;
|
|
1648
1750
|
this._setExpanded(false);
|
|
1649
|
-
this.
|
|
1650
|
-
this._pendingIntents.clear();
|
|
1751
|
+
this._supersedeActivations();
|
|
1651
1752
|
this._suppressGridClick = false;
|
|
1652
1753
|
if (this._drag) {
|
|
1653
1754
|
this._releaseDragCapture(this._drag.pointerId);
|
|
@@ -1676,7 +1777,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1676
1777
|
if (active) {
|
|
1677
1778
|
const index = active === "end" ? 1 : 0;
|
|
1678
1779
|
const field = this._fields?.[index];
|
|
1679
|
-
if (field &&
|
|
1780
|
+
if (field && isEditable(field.input)) return active;
|
|
1680
1781
|
}
|
|
1681
1782
|
return this._resolveRangeEndpoint();
|
|
1682
1783
|
}
|