@lekoala/date-picker 0.5.1 → 0.5.3
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 -294
- package/custom-elements.json +3 -0
- package/dist/date-picker.js +256 -91
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +11 -12
- package/dist/date-picker.standalone.min.js +12 -506
- package/dist/types/date-calendar.d.ts +5 -90
- package/dist/types/date-picker.d.ts +12 -314
- package/dist/types/date.d.ts.map +1 -1
- package/docs/API.md +444 -442
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/DECISIONS.md +74 -74
- package/docs/USE_CASES.md +156 -156
- package/package.json +3 -3
- package/src/activation-intents.js +9 -0
- package/src/date-calendar.js +99 -18
- package/src/date-field.js +57 -10
- package/src/date-picker.js +187 -86
- package/src/date.js +4 -1
- package/dist/types/date-calendar.d.ts.map +0 -1
- package/dist/types/date-field.d.ts +0 -104
- package/dist/types/date-field.d.ts.map +0 -1
- package/dist/types/date-picker.d.ts.map +0 -1
- package/dist/types/time.d.ts +0 -8
- package/dist/types/time.d.ts.map +0 -1
- package/dist/types/upgrade-properties.d.ts +0 -8
- package/dist/types/upgrade-properties.d.ts.map +0 -1
package/src/date-picker.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { autoUpdate, reposition } from "@lekoala/floating";
|
|
2
|
+
import { activationIntents } from "./activation-intents.js";
|
|
2
3
|
import {
|
|
3
4
|
getDefaultControlClasses,
|
|
4
5
|
normalizeControlClasses,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
} from "./control-classes.js";
|
|
8
9
|
import { compareDates, isDate, monthKey, todayISO } from "./date.js";
|
|
9
10
|
import { DateCalendarElement } from "./date-calendar.js";
|
|
10
|
-
import { DateFieldController } from "./date-field.js";
|
|
11
|
+
import { DateFieldController, isEditable } from "./date-field.js";
|
|
11
12
|
import { DateRangeController, normalizeRange } from "./date-range.js";
|
|
12
13
|
import { createDateAdapter, formatLongDate, resolveLocale } from "./intl.js";
|
|
13
14
|
import { getDefaultMessages, setDefaultMessages, subscribeDefaultMessages } from "./messages.js";
|
|
@@ -19,6 +20,10 @@ let uid = 0;
|
|
|
19
20
|
/** Pointer travel, in CSS pixels, that turns a press into an endpoint drag. */
|
|
20
21
|
const DRAG_THRESHOLD = 4;
|
|
21
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
|
+
|
|
22
27
|
export class DatePickerElement extends HTMLElement {
|
|
23
28
|
static observedAttributes = ["value", "locale", "min", "max", "open-on-focus", "month-format"];
|
|
24
29
|
|
|
@@ -62,12 +67,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
62
67
|
this._timeOrderOwned = new Set();
|
|
63
68
|
/** @type {"" | "start" | "end"} */
|
|
64
69
|
this._lastFocusEndpoint = "";
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
* @type {Map<string, {id: number, endpoint: "" | "start" | "end"}>} */
|
|
70
|
-
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;
|
|
71
74
|
/** Candidate date currently projected onto the calendar band, if any. */
|
|
72
75
|
this._previewDate = "";
|
|
73
76
|
/** Live endpoint drag, or null. @type {{pointerId:number, endpoint:"start"|"end", x:number, y:number, date:string, active:boolean} | null} */
|
|
@@ -84,7 +87,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
84
87
|
this._originalDescribedBy = "";
|
|
85
88
|
this._rangeOriginalDescribedBy = ["", ""];
|
|
86
89
|
this._formatHint = null;
|
|
87
|
-
this.
|
|
90
|
+
/** Inputs whose placeholder was generated by this element. */
|
|
91
|
+
this._generatedPlaceholders = new WeakSet();
|
|
88
92
|
this._controller = null;
|
|
89
93
|
this._stopTracking = null;
|
|
90
94
|
this._open = false;
|
|
@@ -95,13 +99,13 @@ export class DatePickerElement extends HTMLElement {
|
|
|
95
99
|
this._controlClasses = null;
|
|
96
100
|
/** @type {(() => void) | null} */
|
|
97
101
|
this._stopDefaultMessages = null;
|
|
98
|
-
/** @type {
|
|
102
|
+
/** @type {import("./date-calendar.js").DateSource | null} */
|
|
99
103
|
this._source = null;
|
|
100
|
-
/** @type {
|
|
104
|
+
/** @type {import("./date-calendar.js").DateStateResolver | null} */
|
|
101
105
|
this._dateState = null;
|
|
102
|
-
/** @type {
|
|
106
|
+
/** @type {import("./date-calendar.js").DayRenderer | null} */
|
|
103
107
|
this._renderDay = null;
|
|
104
|
-
/** @type {
|
|
108
|
+
/** @type {import("./date-calendar.js").DateDisabledPredicate | null} */
|
|
105
109
|
this._isDateDisabled = null;
|
|
106
110
|
/** Public picker coordinate-space request. @type {"viewport" | "document"} */
|
|
107
111
|
this._coordinateSpace = "viewport";
|
|
@@ -111,6 +115,14 @@ export class DatePickerElement extends HTMLElement {
|
|
|
111
115
|
return this.hasAttribute("range") && Boolean(this._range);
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
/** A commit result may only be applied while this picker still owns the
|
|
119
|
+
* controller that produced it. Disconnect tears controllers down and
|
|
120
|
+
* reconnect builds new ones, so a previous instance can never write again.
|
|
121
|
+
* @param {DateFieldController} field */
|
|
122
|
+
_ownsField(field) {
|
|
123
|
+
return this._connected && (field === this._field || Boolean(this._fields?.includes(field)));
|
|
124
|
+
}
|
|
125
|
+
|
|
114
126
|
connectedCallback() {
|
|
115
127
|
if (this._connected) return;
|
|
116
128
|
upgradeProperties(this, [
|
|
@@ -135,6 +147,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
135
147
|
this._stopDefaultMessages = subscribeDefaultMessages(() => {
|
|
136
148
|
if (this._messages === null) this._refreshMessages();
|
|
137
149
|
});
|
|
150
|
+
// Applying the initial value clears custom validity; check it against
|
|
151
|
+
// min/max/isDateDisabled/source like any later constraint change.
|
|
152
|
+
void this.validate();
|
|
138
153
|
}
|
|
139
154
|
}
|
|
140
155
|
|
|
@@ -166,15 +181,11 @@ export class DatePickerElement extends HTMLElement {
|
|
|
166
181
|
else input.setCustomValidity(this.messages.invalidDate);
|
|
167
182
|
|
|
168
183
|
// Native form reset restores control defaultValues, then _restoreDefault()
|
|
169
|
-
// recommits the canonical value from them.
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
// text (picker value attr, no input value attr) does that text become the
|
|
173
|
-
// default to restore.
|
|
184
|
+
// recommits the canonical value from them. Only when enhancement itself
|
|
185
|
+
// supplies the initial text (picker value attr, no input value attr) does
|
|
186
|
+
// that text become the default to restore.
|
|
174
187
|
if (this._field.hidden) this._field.hidden.defaultValue = this._value;
|
|
175
|
-
|
|
176
|
-
input.defaultValue = input.value;
|
|
177
|
-
}
|
|
188
|
+
this._field.adoptDefault();
|
|
178
189
|
}
|
|
179
190
|
|
|
180
191
|
/**
|
|
@@ -241,25 +252,29 @@ export class DatePickerElement extends HTMLElement {
|
|
|
241
252
|
const adapter = this._adapter();
|
|
242
253
|
const initialStart = isDate(startInput.value) ? startInput.value : adapter.parse(startInput.value);
|
|
243
254
|
const initialEnd = isDate(endInput.value) ? endInput.value : adapter.parse(endInput.value);
|
|
255
|
+
// Read the authored text before _setRange() reformats an unparsable one away.
|
|
256
|
+
const startText = startInput.value;
|
|
257
|
+
const endText = endInput.value;
|
|
244
258
|
this._setRange(initialStart || "", initialEnd || "", { emit: false });
|
|
245
|
-
|
|
246
|
-
startInput
|
|
247
|
-
|
|
259
|
+
for (const [input, text, initial] of /** @type {const} */ ([
|
|
260
|
+
[startInput, startText, initialStart],
|
|
261
|
+
[endInput, endText, initialEnd],
|
|
262
|
+
])) {
|
|
263
|
+
if (!text.trim() || isDate(initial)) continue;
|
|
264
|
+
input.value = text;
|
|
265
|
+
input.setCustomValidity(this.messages.invalidDate);
|
|
266
|
+
}
|
|
248
267
|
|
|
249
268
|
if (this._fields[0].hidden) this._fields[0].hidden.defaultValue = this._range.start;
|
|
250
269
|
if (this._fields[1].hidden) this._fields[1].hidden.defaultValue = this._range.end;
|
|
251
|
-
|
|
252
|
-
startInput.defaultValue = startInput.value;
|
|
253
|
-
}
|
|
254
|
-
if (endInput.getAttribute("value") == null && endInput.defaultValue === "") {
|
|
255
|
-
endInput.defaultValue = endInput.value;
|
|
256
|
-
}
|
|
270
|
+
for (const field of this._fields) field.adoptDefault();
|
|
257
271
|
}
|
|
258
272
|
|
|
259
273
|
disconnectedCallback() {
|
|
260
274
|
this._stopDefaultMessages?.();
|
|
261
275
|
this._stopDefaultMessages = null;
|
|
262
276
|
this._connected = false;
|
|
277
|
+
this._suppressFocusOpen = false;
|
|
263
278
|
this.hide(false);
|
|
264
279
|
this._controller?.abort();
|
|
265
280
|
this._controller = null;
|
|
@@ -301,11 +316,26 @@ export class DatePickerElement extends HTMLElement {
|
|
|
301
316
|
|
|
302
317
|
/** @param {string} name @param {string|null} oldValue @param {string|null} newValue */
|
|
303
318
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
304
|
-
if (
|
|
319
|
+
if (this._reflecting || oldValue === newValue) return;
|
|
320
|
+
if (name === "value" && newValue && !isDate(newValue)) {
|
|
321
|
+
this._reflectValue(this._value);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if ((name === "min" || name === "max") && newValue && !isDate(newValue)) {
|
|
325
|
+
this.removeAttribute(name);
|
|
326
|
+
if (this._connected) {
|
|
327
|
+
this._syncCalendarOptions();
|
|
328
|
+
void this.validate();
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!this._connected) return;
|
|
305
333
|
if (name === "value") {
|
|
306
334
|
if (this._rangeMode()) return;
|
|
307
|
-
if (!newValue || isDate(newValue))
|
|
335
|
+
if (!newValue || isDate(newValue)) {
|
|
336
|
+
this._supersedeActivations();
|
|
308
337
|
this._setValue(newValue || "", { emit: false, format: true, reflect: false });
|
|
338
|
+
}
|
|
309
339
|
return;
|
|
310
340
|
}
|
|
311
341
|
if (name === "locale") this._refreshLocale();
|
|
@@ -334,6 +364,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
334
364
|
throw new TypeError("value is not available in range mode; use range");
|
|
335
365
|
}
|
|
336
366
|
if (value && !isDate(value)) throw new TypeError(`Invalid date-picker value: ${value}`);
|
|
367
|
+
this._supersedeActivations();
|
|
337
368
|
this._setValue(value || "", { emit: false, format: true });
|
|
338
369
|
}
|
|
339
370
|
|
|
@@ -345,6 +376,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
345
376
|
set range(value) {
|
|
346
377
|
if (!this._rangeMode()) throw new TypeError("range is only available on <date-picker range>");
|
|
347
378
|
const { start, end } = normalizeRange(value);
|
|
379
|
+
this._supersedeActivations();
|
|
348
380
|
this._setRange(start, end);
|
|
349
381
|
}
|
|
350
382
|
|
|
@@ -444,7 +476,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
444
476
|
if (this._connected) void this.validate();
|
|
445
477
|
}
|
|
446
478
|
|
|
447
|
-
/** @public @returns {
|
|
479
|
+
/** @public @returns {import("./date-calendar.js").DateSource | null} */
|
|
448
480
|
get source() {
|
|
449
481
|
return this._source;
|
|
450
482
|
}
|
|
@@ -455,7 +487,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
455
487
|
if (this._connected) void this.validate();
|
|
456
488
|
}
|
|
457
489
|
|
|
458
|
-
/** @public @returns {
|
|
490
|
+
/** @public @returns {import("./date-calendar.js").DateStateResolver | null} */
|
|
459
491
|
get dateState() {
|
|
460
492
|
return this._dateState;
|
|
461
493
|
}
|
|
@@ -466,7 +498,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
466
498
|
if (this._connected) void this.validate();
|
|
467
499
|
}
|
|
468
500
|
|
|
469
|
-
/** @public @returns {
|
|
501
|
+
/** @public @returns {import("./date-calendar.js").DayRenderer | null} */
|
|
470
502
|
get renderDay() {
|
|
471
503
|
return this._renderDay;
|
|
472
504
|
}
|
|
@@ -476,7 +508,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
476
508
|
if (this._calendar) this._calendar.renderDay = this._renderDay;
|
|
477
509
|
}
|
|
478
510
|
|
|
479
|
-
/** @public @returns {
|
|
511
|
+
/** @public @returns {import("./date-calendar.js").DateDisabledPredicate | null} */
|
|
480
512
|
get isDateDisabled() {
|
|
481
513
|
return this._isDateDisabled;
|
|
482
514
|
}
|
|
@@ -511,8 +543,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
511
543
|
// movement lives in the field controller.
|
|
512
544
|
this._field?.syncInputState();
|
|
513
545
|
const button = this._buttons[0]?.button;
|
|
546
|
+
// Own state only: a disabled ancestor fieldset already disables the button
|
|
547
|
+
// natively, and copying it would stick once the fieldset is re-enabled.
|
|
514
548
|
if (button) button.disabled = input.disabled || input.readOnly;
|
|
515
|
-
if (this._open && (input
|
|
549
|
+
if (this._open && !isEditable(input)) this.hide(false);
|
|
516
550
|
}
|
|
517
551
|
|
|
518
552
|
_syncRangeFields() {
|
|
@@ -527,9 +561,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
527
561
|
}
|
|
528
562
|
this._syncDragAffordance();
|
|
529
563
|
const active = this._range?.activeEndpoint === "end" ? 1 : 0;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}
|
|
564
|
+
const activeInput = this._fields[active]?.input;
|
|
565
|
+
if (this._open && activeInput && !isEditable(activeInput)) this.hide(false);
|
|
533
566
|
}
|
|
534
567
|
|
|
535
568
|
_setRangeAria() {
|
|
@@ -647,7 +680,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
647
680
|
// Reset is cancelable and runs before native values have been restored.
|
|
648
681
|
this.hide(false);
|
|
649
682
|
queueMicrotask(() => {
|
|
650
|
-
if (!event.defaultPrevented) restore();
|
|
683
|
+
if (!signal.aborted && !event.defaultPrevented) restore();
|
|
651
684
|
});
|
|
652
685
|
},
|
|
653
686
|
{ capture: true, signal },
|
|
@@ -710,7 +743,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
710
743
|
// open-on-focus, where the popup is already open when Tab reaches the
|
|
711
744
|
// button; Escape keeps closing it. Defer focus as in the input handler.
|
|
712
745
|
if (event.detail === 0) {
|
|
713
|
-
|
|
746
|
+
this._defer(() => this._calendar?.focusGrid());
|
|
714
747
|
return;
|
|
715
748
|
}
|
|
716
749
|
this.hide(false);
|
|
@@ -721,17 +754,27 @@ export class DatePickerElement extends HTMLElement {
|
|
|
721
754
|
{ signal },
|
|
722
755
|
);
|
|
723
756
|
button.addEventListener("keydown", (event) => this._onTriggerKeyDown("", event), { signal });
|
|
757
|
+
calendar.addEventListener("click", (event) => this._captureGridIntent(event), { capture: true, signal });
|
|
758
|
+
calendar.addEventListener("keydown", (event) => this._captureGridIntent(event), {
|
|
759
|
+
capture: true,
|
|
760
|
+
signal,
|
|
761
|
+
});
|
|
724
762
|
calendar.addEventListener(
|
|
725
763
|
"dateactivate",
|
|
726
764
|
(event) => {
|
|
727
765
|
const custom = /** @type {CustomEvent} */ (event);
|
|
728
766
|
const date = custom.detail?.date;
|
|
729
767
|
if (!isDate(date)) return;
|
|
768
|
+
const intent = /** @type {PickIntent | undefined} */ (activationIntents.get(custom));
|
|
730
769
|
// The app listener on <date-picker> runs after this during bubbling;
|
|
731
770
|
// defer the commit so preventDefault() is actually respected.
|
|
732
771
|
queueMicrotask(() => {
|
|
733
|
-
if (custom.defaultPrevented || !this._connected) return;
|
|
734
|
-
|
|
772
|
+
if (signal.aborted || custom.defaultPrevented || !this._connected) return;
|
|
773
|
+
// A grid pick that resolves after the popup closed, after a newer pick
|
|
774
|
+
// or after a programmatic value must not overwrite it. A programmatic
|
|
775
|
+
// calendar.activateDate() carries no intent and still commits.
|
|
776
|
+
if (intent && intent.id !== this._activationId) return;
|
|
777
|
+
if (!this._input || !isEditable(this._input)) return;
|
|
735
778
|
this._field?.dirty();
|
|
736
779
|
this._setValue(date, { emit: true, format: true });
|
|
737
780
|
this.hide(false);
|
|
@@ -755,7 +798,16 @@ export class DatePickerElement extends HTMLElement {
|
|
|
755
798
|
|
|
756
799
|
for (const [index, field] of this._fields.entries()) {
|
|
757
800
|
const endpoint = index === 1 ? "end" : "start";
|
|
758
|
-
field.input.addEventListener(
|
|
801
|
+
field.input.addEventListener(
|
|
802
|
+
"input",
|
|
803
|
+
() => {
|
|
804
|
+
// handleInput() rewrites the custom validity: any order error is gone.
|
|
805
|
+
field.handleInput();
|
|
806
|
+
this._orderTags.delete(index);
|
|
807
|
+
if (!field.isDirty) this._revalidateRange(endpoint);
|
|
808
|
+
},
|
|
809
|
+
{ signal },
|
|
810
|
+
);
|
|
759
811
|
field.input.addEventListener("change", () => void this._commitFieldText(endpoint), { signal });
|
|
760
812
|
field.input.addEventListener("blur", () => void this._commitFieldText(endpoint), { signal });
|
|
761
813
|
field.input.addEventListener(
|
|
@@ -805,12 +857,11 @@ export class DatePickerElement extends HTMLElement {
|
|
|
805
857
|
const date = custom.detail?.date;
|
|
806
858
|
if (!isDate(date)) return;
|
|
807
859
|
queueMicrotask(() => {
|
|
808
|
-
if (custom.defaultPrevented || !this._connected || !this._range) return;
|
|
809
|
-
const intent =
|
|
860
|
+
if (signal.aborted || custom.defaultPrevented || !this._connected || !this._range) return;
|
|
861
|
+
const intent = /** @type {PickIntent | undefined} */ (activationIntents.get(custom));
|
|
810
862
|
// A response is dropped when the active endpoint changed, the popup
|
|
811
863
|
// closed or a newer activation superseded this one.
|
|
812
|
-
if (intent === undefined || intent.id !== this.
|
|
813
|
-
this._pendingIntents.delete(date);
|
|
864
|
+
if (intent === undefined || intent.id !== this._activationId) return;
|
|
814
865
|
const editable = (/** @type {"start" | "end"} */ bound) => this._boundEditable(bound);
|
|
815
866
|
// Click, keyboard and drop share this one machine; only the entry
|
|
816
867
|
// point differs, a drag naming the handle it grabbed.
|
|
@@ -863,8 +914,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
863
914
|
const calendar = this._calendar;
|
|
864
915
|
if (!this._range || !calendar) return;
|
|
865
916
|
this._range.focus(endpoint);
|
|
866
|
-
this.
|
|
867
|
-
this._pendingIntents.clear();
|
|
917
|
+
this._activationId++;
|
|
868
918
|
this._lastFocusEndpoint = endpoint;
|
|
869
919
|
const target = this._boundCanonical(endpoint) || todayISO();
|
|
870
920
|
calendar.display = monthKey(target);
|
|
@@ -890,7 +940,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
890
940
|
// Keyboard activation of an already-open picker: switch the bound, then
|
|
891
941
|
// move into the grid. Escape keeps closing it.
|
|
892
942
|
this._activateRangeEndpoint(endpoint);
|
|
893
|
-
|
|
943
|
+
this._defer(() => this._calendar?.focusGrid());
|
|
894
944
|
return;
|
|
895
945
|
}
|
|
896
946
|
if (endpoint !== this._range?.activeEndpoint) {
|
|
@@ -913,7 +963,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
913
963
|
if (this._open) {
|
|
914
964
|
if (this._rangeMode() && endpoint) this._activateRangeEndpoint(endpoint);
|
|
915
965
|
// Chromium drops script-initiated focus changes during keydown dispatch.
|
|
916
|
-
|
|
966
|
+
this._defer(() => this._calendar?.focusGrid());
|
|
917
967
|
return;
|
|
918
968
|
}
|
|
919
969
|
if (this._rangeMode()) this.show({ endpoint: endpoint || undefined });
|
|
@@ -931,7 +981,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
931
981
|
if (this._open) {
|
|
932
982
|
// Chromium drops script-initiated focus changes made during keydown
|
|
933
983
|
// dispatch; defer to a macrotask so the grid can take keyboard focus.
|
|
934
|
-
|
|
984
|
+
this._defer(() => this._calendar?.focusGrid());
|
|
935
985
|
} else {
|
|
936
986
|
this.show();
|
|
937
987
|
}
|
|
@@ -950,8 +1000,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
950
1000
|
if (switched) {
|
|
951
1001
|
// A late calendar activation targeting the previous endpoint must not
|
|
952
1002
|
// commit after the intent moved to the other bound.
|
|
953
|
-
this.
|
|
954
|
-
this._pendingIntents.clear();
|
|
1003
|
+
this._activationId++;
|
|
955
1004
|
}
|
|
956
1005
|
if (!this.openOnFocus || this._suppressFocusOpen) return;
|
|
957
1006
|
const related = event.relatedTarget;
|
|
@@ -959,30 +1008,49 @@ export class DatePickerElement extends HTMLElement {
|
|
|
959
1008
|
if (!this._open) this.show({ moveFocus: false });
|
|
960
1009
|
}
|
|
961
1010
|
|
|
962
|
-
/** Record
|
|
1011
|
+
/** Record grid activation intent (click or keyboard) so a stale,
|
|
963
1012
|
* late-confirmed dateactivate can be recognized and dropped. Each new grid
|
|
964
1013
|
* activation supersedes every earlier pending one.
|
|
965
1014
|
* @param {Event} event */
|
|
966
1015
|
_captureGridIntent(event) {
|
|
967
|
-
if (
|
|
1016
|
+
if (this._rangeMode() ? !this._range : !this._field) return;
|
|
968
1017
|
if (event.type === "keydown") {
|
|
969
1018
|
const key = /** @type {KeyboardEvent} */ (event).key;
|
|
970
1019
|
if (key !== "Enter" && key !== " ") return;
|
|
971
1020
|
}
|
|
1021
|
+
// The click tail of a drag never activates: it must not supersede the drop.
|
|
1022
|
+
if (event.type === "click" && this._suppressGridClick) return;
|
|
972
1023
|
if (!(event.target instanceof Element)) return;
|
|
973
1024
|
const cell = event.target.closest(".dp-day[data-date]");
|
|
974
1025
|
const date = cell?.getAttribute("data-date") || "";
|
|
975
1026
|
if (!isDate(date)) return;
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1027
|
+
this._attachIntent(date, "");
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Hand a new intent to the calendar's next activation of `date`. Taking a
|
|
1032
|
+
* new generation supersedes every earlier pending activation.
|
|
1033
|
+
* @param {string} date @param {"" | "start" | "end"} endpoint
|
|
1034
|
+
*/
|
|
1035
|
+
_attachIntent(date, endpoint) {
|
|
1036
|
+
if (!this._calendar) return;
|
|
1037
|
+
this._calendar._hostIntent = { date, intent: { id: ++this._activationId, endpoint } };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Invalidate every pending grid activation: each one carries its own intent,
|
|
1042
|
+
* which now fails the generation check. A programmatic
|
|
1043
|
+
* `calendar.activateDate()` carries none.
|
|
1044
|
+
*/
|
|
1045
|
+
_supersedeActivations() {
|
|
1046
|
+
this._activationId++;
|
|
979
1047
|
}
|
|
980
1048
|
|
|
981
1049
|
/** Whether one bound can receive a user pick right now.
|
|
982
1050
|
* @param {"start" | "end"} bound */
|
|
983
1051
|
_boundEditable(bound) {
|
|
984
1052
|
const field = this._fields?.[bound === "end" ? 1 : 0];
|
|
985
|
-
return field != null &&
|
|
1053
|
+
return field != null && isEditable(field.input);
|
|
986
1054
|
}
|
|
987
1055
|
|
|
988
1056
|
/**
|
|
@@ -1180,8 +1248,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1180
1248
|
// guard: a late response can never move a bound after the popup closed or
|
|
1181
1249
|
// after a newer interaction.
|
|
1182
1250
|
const target = drag.endpoint === "start" ? transition.range.start : transition.range.end;
|
|
1183
|
-
this.
|
|
1184
|
-
this._pendingIntents.set(target, { id: ++this._rangeCommitId, endpoint: drag.endpoint });
|
|
1251
|
+
this._attachIntent(target, drag.endpoint);
|
|
1185
1252
|
void this._calendar?.activateDate(target);
|
|
1186
1253
|
}
|
|
1187
1254
|
|
|
@@ -1253,9 +1320,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1253
1320
|
this._fields[1].setLocale(this.locale);
|
|
1254
1321
|
hint.textContent = `${this.messages.formatHint}: ${this._fields[0].placeholder}`;
|
|
1255
1322
|
for (const [index, field] of this._fields.entries()) {
|
|
1256
|
-
if (!field.input.hasAttribute("placeholder") || this.
|
|
1323
|
+
if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholders.has(field.input)) {
|
|
1257
1324
|
field.input.placeholder = field.placeholder;
|
|
1258
|
-
this.
|
|
1325
|
+
this._generatedPlaceholders.add(field.input);
|
|
1259
1326
|
}
|
|
1260
1327
|
const canonical = index === 1 ? this._range?.end || "" : this._range?.start || "";
|
|
1261
1328
|
if (canonical) field.input.value = field.format(canonical);
|
|
@@ -1267,9 +1334,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1267
1334
|
if (!field) return;
|
|
1268
1335
|
field.setLocale(this.locale);
|
|
1269
1336
|
hint.textContent = `${this.messages.formatHint}: ${field.placeholder}`;
|
|
1270
|
-
if (!field.input.hasAttribute("placeholder") || this.
|
|
1337
|
+
if (!field.input.hasAttribute("placeholder") || this._generatedPlaceholders.has(field.input)) {
|
|
1271
1338
|
field.input.placeholder = field.placeholder;
|
|
1272
|
-
this.
|
|
1339
|
+
this._generatedPlaceholders.add(field.input);
|
|
1273
1340
|
}
|
|
1274
1341
|
if (this._value) field.input.value = field.format(this._value);
|
|
1275
1342
|
this._refreshButtonLabel();
|
|
@@ -1364,6 +1431,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1364
1431
|
// current value against the latest constraints regardless of dirtiness.
|
|
1365
1432
|
if (!options.force && !field.isDirty) return false;
|
|
1366
1433
|
const result = await field.commit(options);
|
|
1434
|
+
if (!this._ownsField(field) || !field.isCommitCurrent(result.commitId)) {
|
|
1435
|
+
return false;
|
|
1436
|
+
}
|
|
1367
1437
|
if (result.status === "stale" || result.status === "invalid") return false;
|
|
1368
1438
|
this._setValue(result.value || "", {
|
|
1369
1439
|
emit,
|
|
@@ -1381,6 +1451,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1381
1451
|
const field = fields[index];
|
|
1382
1452
|
if (!field?.isDirty) return false;
|
|
1383
1453
|
const result = await field.commit();
|
|
1454
|
+
if (!this._ownsField(field) || !field.isCommitCurrent(result.commitId)) return false;
|
|
1384
1455
|
if (result.status === "stale") return false;
|
|
1385
1456
|
if (result.status === "invalid") {
|
|
1386
1457
|
this._orderTags.delete(index);
|
|
@@ -1403,7 +1474,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1403
1474
|
if (!field) return false;
|
|
1404
1475
|
// User initiated picks must respect disabled/readonly bounds; reset,
|
|
1405
1476
|
// explicit validation and programmatic assignments keep their own path.
|
|
1406
|
-
if (options.user === true && (field.input
|
|
1477
|
+
if (options.user === true && !isEditable(field.input)) return false;
|
|
1407
1478
|
const next = value || "";
|
|
1408
1479
|
// The range model may already hold the target (a calendar activation moved
|
|
1409
1480
|
// it), so change detection reads the field canonical, not the model.
|
|
@@ -1509,8 +1580,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1509
1580
|
/** @type {["start", "end"]} */
|
|
1510
1581
|
const bounds = ["start", "end"];
|
|
1511
1582
|
const inputs = [startInput, endInput];
|
|
1512
|
-
const startValue = startInput && !startInput.disabled ? startInput.value.trim() : "";
|
|
1513
|
-
const endValue = endInput && !endInput.disabled ? endInput.value.trim() : "";
|
|
1583
|
+
const startValue = startInput && !startInput.matches(":disabled") ? startInput.value.trim() : "";
|
|
1584
|
+
const endValue = endInput && !endInput.matches(":disabled") ? endInput.value.trim() : "";
|
|
1514
1585
|
const inverted =
|
|
1515
1586
|
Boolean(startInput && endInput && startValue && endValue) &&
|
|
1516
1587
|
isTime(startValue) &&
|
|
@@ -1536,6 +1607,17 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1536
1607
|
this._timeOrderOwned.clear();
|
|
1537
1608
|
}
|
|
1538
1609
|
|
|
1610
|
+
/** Run a timer only while the connection that scheduled it remains active.
|
|
1611
|
+
* @param {() => void} callback */
|
|
1612
|
+
_defer(callback) {
|
|
1613
|
+
const controller = this._controller;
|
|
1614
|
+
setTimeout(() => {
|
|
1615
|
+
if (!this._connected || !controller || controller.signal.aborted || this._controller !== controller)
|
|
1616
|
+
return;
|
|
1617
|
+
callback();
|
|
1618
|
+
}, 0);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1539
1621
|
_restoreDefault() {
|
|
1540
1622
|
const field = this._field;
|
|
1541
1623
|
if (!field) return;
|
|
@@ -1572,10 +1654,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1572
1654
|
const input = this._fields?.[index]?.input;
|
|
1573
1655
|
if (!input) return;
|
|
1574
1656
|
this._suppressFocusOpen = true;
|
|
1575
|
-
|
|
1657
|
+
this._defer(() => {
|
|
1576
1658
|
input.focus();
|
|
1577
1659
|
this._suppressFocusOpen = false;
|
|
1578
|
-
}
|
|
1660
|
+
});
|
|
1579
1661
|
}
|
|
1580
1662
|
|
|
1581
1663
|
/**
|
|
@@ -1589,10 +1671,10 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1589
1671
|
this._returnFocus = null;
|
|
1590
1672
|
if (!(target instanceof HTMLElement) || !target.isConnected || target.matches(":disabled")) return;
|
|
1591
1673
|
this._suppressFocusOpen = true;
|
|
1592
|
-
|
|
1674
|
+
this._defer(() => {
|
|
1593
1675
|
target.focus();
|
|
1594
1676
|
this._suppressFocusOpen = false;
|
|
1595
|
-
}
|
|
1677
|
+
});
|
|
1596
1678
|
}
|
|
1597
1679
|
|
|
1598
1680
|
/** @returns {"" | "start" | "end"} */
|
|
@@ -1600,12 +1682,12 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1600
1682
|
if (!this._fields) return "";
|
|
1601
1683
|
const preferred = this._lastFocusEndpoint || "start";
|
|
1602
1684
|
const preferredIndex = preferred === "end" ? 1 : 0;
|
|
1603
|
-
if (
|
|
1685
|
+
if (isEditable(this._fields[preferredIndex].input)) {
|
|
1604
1686
|
return preferred;
|
|
1605
1687
|
}
|
|
1606
1688
|
const alternative = preferred === "end" ? "start" : "end";
|
|
1607
1689
|
const alternativeIndex = alternative === "end" ? 1 : 0;
|
|
1608
|
-
if (
|
|
1690
|
+
if (isEditable(this._fields[alternativeIndex].input)) {
|
|
1609
1691
|
return alternative;
|
|
1610
1692
|
}
|
|
1611
1693
|
return "";
|
|
@@ -1621,6 +1703,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1621
1703
|
// Empty text flows through commit()'s "clear" outcome, not an early exit.
|
|
1622
1704
|
for (const [index, field] of fields.entries()) {
|
|
1623
1705
|
const result = await field.commit({ moveDisplay: false });
|
|
1706
|
+
if (!this._ownsField(field) || !field.isCommitCurrent(result.commitId)) continue;
|
|
1707
|
+
// A parse/source error replaced the order error, which no longer owns it.
|
|
1708
|
+
if (result.status === "invalid") this._orderTags.delete(index);
|
|
1624
1709
|
if (result.status === "ok" || result.status === "clear") {
|
|
1625
1710
|
const which = index === 1 ? "end" : "start";
|
|
1626
1711
|
this._setBound(which, result.value || "", { format: result.status === "ok" });
|
|
@@ -1631,12 +1716,22 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1631
1716
|
return fields.every((field) => field.input.checkValidity());
|
|
1632
1717
|
}
|
|
1633
1718
|
const input = this._field?.input;
|
|
1634
|
-
if (!input)
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1719
|
+
if (!input) {
|
|
1720
|
+
const dateInputs = this.hasAttribute("range")
|
|
1721
|
+
? this.querySelectorAll(":scope > input[data-range-start], :scope > input[data-range-end]")
|
|
1722
|
+
: this._input
|
|
1723
|
+
? [this._input]
|
|
1724
|
+
: [];
|
|
1725
|
+
const timeInputs = this.querySelectorAll(
|
|
1726
|
+
":scope > input[type=time][data-time-start], :scope > input[type=time][data-time-end]",
|
|
1727
|
+
);
|
|
1728
|
+
return [...dateInputs, ...timeInputs].every(
|
|
1729
|
+
(candidate) => candidate instanceof HTMLInputElement && candidate.checkValidity(),
|
|
1730
|
+
);
|
|
1639
1731
|
}
|
|
1732
|
+
// Empty text flows through commit()'s "clear" outcome so the canonical
|
|
1733
|
+
// value and hidden ISO are cleared with it.
|
|
1734
|
+
await this._commitText(false, { force: true, moveDisplay: false });
|
|
1640
1735
|
// Same start-then-end order as the range path, so a from/to inversion
|
|
1641
1736
|
// ends reported on "end". A programmatic time change resyncs here too.
|
|
1642
1737
|
this._revalidateTimeOrder("start");
|
|
@@ -1656,7 +1751,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1656
1751
|
this._activateRangeEndpoint(endpoint);
|
|
1657
1752
|
} else {
|
|
1658
1753
|
const input = this._input;
|
|
1659
|
-
if (!input || input
|
|
1754
|
+
if (!input || !isEditable(input)) return;
|
|
1660
1755
|
const target = this._value || this._adapter().parse(input.value) || todayISO();
|
|
1661
1756
|
calendar.display = monthKey(target);
|
|
1662
1757
|
calendar.focusedDate = target;
|
|
@@ -1680,7 +1775,14 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1680
1775
|
});
|
|
1681
1776
|
position();
|
|
1682
1777
|
this._stopTracking = autoUpdate(this, panel, position);
|
|
1683
|
-
if (options.moveFocus !== false)
|
|
1778
|
+
if (options.moveFocus !== false) {
|
|
1779
|
+
const controller = this._controller;
|
|
1780
|
+
queueMicrotask(() => {
|
|
1781
|
+
if (!this._connected || !controller || controller.signal.aborted || this._controller !== controller)
|
|
1782
|
+
return;
|
|
1783
|
+
calendar.focusGrid();
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1684
1786
|
this.dispatchEvent(new Event("open", { bubbles: true }));
|
|
1685
1787
|
}
|
|
1686
1788
|
|
|
@@ -1704,8 +1806,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1704
1806
|
}
|
|
1705
1807
|
this._open = false;
|
|
1706
1808
|
this._setExpanded(false);
|
|
1707
|
-
this.
|
|
1708
|
-
this._pendingIntents.clear();
|
|
1809
|
+
this._supersedeActivations();
|
|
1709
1810
|
this._suppressGridClick = false;
|
|
1710
1811
|
if (this._drag) {
|
|
1711
1812
|
this._releaseDragCapture(this._drag.pointerId);
|
|
@@ -1734,7 +1835,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1734
1835
|
if (active) {
|
|
1735
1836
|
const index = active === "end" ? 1 : 0;
|
|
1736
1837
|
const field = this._fields?.[index];
|
|
1737
|
-
if (field &&
|
|
1838
|
+
if (field && isEditable(field.input)) return active;
|
|
1738
1839
|
}
|
|
1739
1840
|
return this._resolveRangeEndpoint();
|
|
1740
1841
|
}
|
package/src/date.js
CHANGED
|
@@ -42,8 +42,11 @@ export function monthKey(value) {
|
|
|
42
42
|
const text = String(value || "");
|
|
43
43
|
const monthMatch = MONTH_RE.exec(text);
|
|
44
44
|
if (monthMatch) {
|
|
45
|
+
const year = Number(monthMatch[1]);
|
|
45
46
|
const month = Number(monthMatch[2]);
|
|
46
|
-
if (month >= 1 && month <= 12)
|
|
47
|
+
if (year >= 1 && year <= 9999 && month >= 1 && month <= 12) {
|
|
48
|
+
return `${monthMatch[1]}-${String(month).padStart(2, "0")}`;
|
|
49
|
+
}
|
|
47
50
|
}
|
|
48
51
|
const parsed = parseDate(text);
|
|
49
52
|
if (!parsed) throw new TypeError(`Invalid civil date/month: ${value}`);
|