@lekoala/date-picker 0.1.1 → 0.3.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 +33 -8
- package/custom-elements.json +54 -0
- package/dist/date-picker.css +223 -76
- package/dist/date-picker.js +327 -90
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +237 -90
- package/dist/types/date-calendar.d.ts +3 -0
- package/dist/types/date-calendar.d.ts.map +1 -1
- package/dist/types/date-field.d.ts +3 -1
- package/dist/types/date-field.d.ts.map +1 -1
- package/dist/types/date-picker.d.ts +86 -5
- 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/ACCESSIBILITY.md +17 -9
- package/docs/API.md +68 -2
- package/docs/ARCHITECTURE.md +16 -2
- package/docs/AT-CHECKLIST.md +1 -1
- package/docs/DECISIONS.md +10 -1
- package/docs/MIGRATION-FLATPICKR.md +1 -1
- package/docs/ROADMAP.md +3 -2
- package/docs/USE_CASES.md +14 -1
- package/package.json +5 -5
- package/src/date-calendar.js +45 -13
- package/src/date-field.js +12 -4
- package/src/date-picker.css +223 -76
- package/src/date-picker.js +314 -80
- 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,13 +26,21 @@ 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;
|
|
31
36
|
/** Map of clicked/keyboard-activated grid dates to the generation at click
|
|
32
37
|
* time (range mode supersession/stale protection). @type {Map<string, number>} */
|
|
33
38
|
this._pendingIntents = new Map();
|
|
34
|
-
|
|
39
|
+
/** Overlaid calendar triggers, one per field (single) or per bound (range). @type {{button: HTMLButtonElement, endpoint: "" | "start" | "end"}[]} */
|
|
40
|
+
this._buttons = [];
|
|
41
|
+
/** Control that opened the popover, so single mode can restore focus to it.
|
|
42
|
+
* @type {HTMLElement | null} */
|
|
43
|
+
this._returnFocus = null;
|
|
35
44
|
this._panel = null;
|
|
36
45
|
this._calendar = null;
|
|
37
46
|
this._originalDescribedBy = "";
|
|
@@ -51,6 +60,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
51
60
|
this._renderDay = null;
|
|
52
61
|
/** @type {any} */
|
|
53
62
|
this._isDateDisabled = null;
|
|
63
|
+
/** Public picker coordinate-space request. @type {"viewport" | "document"} */
|
|
64
|
+
this._coordinateSpace = "viewport";
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
_rangeMode() {
|
|
@@ -67,13 +78,16 @@ export class DatePickerElement extends HTMLElement {
|
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
_connectSingle() {
|
|
70
|
-
const input = this.querySelector(
|
|
81
|
+
const input = this.querySelector(
|
|
82
|
+
":scope > input:not([type=hidden]):not([type=time]):not([data-time-start]):not([data-time-end])",
|
|
83
|
+
);
|
|
71
84
|
if (!(input instanceof HTMLInputElement)) {
|
|
72
85
|
console.warn("<date-picker> expects a direct child text input");
|
|
73
86
|
return;
|
|
74
87
|
}
|
|
75
88
|
this._connected = true;
|
|
76
89
|
this._input = input;
|
|
90
|
+
this._discoverTimeFields();
|
|
77
91
|
this._field = new DateFieldController(input, { messages: this._messages, locale: this.locale });
|
|
78
92
|
this._field.onAttributesChanged = () => this._syncInputState();
|
|
79
93
|
this._field.confirm = (date) => this._confirmDate(date);
|
|
@@ -102,7 +116,37 @@ export class DatePickerElement extends HTMLElement {
|
|
|
102
116
|
}
|
|
103
117
|
}
|
|
104
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Find optional native time companions. They stay fully native (no hidden
|
|
121
|
+
* input, no field controller); the picker only coordinates from/to order.
|
|
122
|
+
* A bad composition never breaks the date picker: warn and leave times alone.
|
|
123
|
+
*/
|
|
124
|
+
_discoverTimeFields() {
|
|
125
|
+
this._timeFields = [null, null];
|
|
126
|
+
/** @type {["data-time-start", "data-time-end"]} */
|
|
127
|
+
const markers = ["data-time-start", "data-time-end"];
|
|
128
|
+
for (const [index, marker] of markers.entries()) {
|
|
129
|
+
const matches = this.querySelectorAll(`:scope > input[${marker}]`);
|
|
130
|
+
if (matches.length > 1) {
|
|
131
|
+
console.warn(`<date-picker> expects at most one direct child input[${marker}]; ignoring extras`);
|
|
132
|
+
}
|
|
133
|
+
const candidate = matches[0];
|
|
134
|
+
if (candidate == null) continue;
|
|
135
|
+
if (!(candidate instanceof HTMLInputElement) || candidate.type !== "time") {
|
|
136
|
+
console.warn(`<date-picker> input[${marker}] must be an <input type="time">; ignoring it`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
this._timeFields[index] = candidate;
|
|
140
|
+
}
|
|
141
|
+
if (this.querySelector(":scope > input[type=time]:not([data-time-start]):not([data-time-end])")) {
|
|
142
|
+
console.warn("<date-picker> time inputs need [data-time-start] or [data-time-end]; ignoring it");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
105
146
|
_connectRange() {
|
|
147
|
+
if (this.querySelector(":scope > input[data-time-start], :scope > input[data-time-end]")) {
|
|
148
|
+
console.warn("<date-picker range> time companions are not supported yet; leaving them native");
|
|
149
|
+
}
|
|
106
150
|
const startInput = this.querySelector(":scope > input[data-range-start]");
|
|
107
151
|
const endInput = this.querySelector(":scope > input[data-range-end]");
|
|
108
152
|
if (!(startInput instanceof HTMLInputElement) || !(endInput instanceof HTMLInputElement)) {
|
|
@@ -127,7 +171,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
127
171
|
field.confirm = (date) => this._confirmDate(date);
|
|
128
172
|
field.setupFormValue();
|
|
129
173
|
}
|
|
130
|
-
this._build(
|
|
174
|
+
this._build();
|
|
131
175
|
this._setRangeAria();
|
|
132
176
|
this._syncRangeFields();
|
|
133
177
|
this._bindRange();
|
|
@@ -156,6 +200,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
156
200
|
this.hide(false);
|
|
157
201
|
this._controller?.abort();
|
|
158
202
|
this._controller = null;
|
|
203
|
+
this._clearTimeOrderValidity();
|
|
204
|
+
this._timeFields = [null, null];
|
|
159
205
|
if (this._rangeMode()) {
|
|
160
206
|
for (const field of this._fields || []) this._teardownField(field);
|
|
161
207
|
this._fields = null;
|
|
@@ -164,10 +210,11 @@ export class DatePickerElement extends HTMLElement {
|
|
|
164
210
|
this._teardownField(this._field);
|
|
165
211
|
}
|
|
166
212
|
this._field = null;
|
|
167
|
-
this.
|
|
213
|
+
for (const { button } of this._buttons) button.remove();
|
|
214
|
+
this._buttons = [];
|
|
215
|
+
this._returnFocus = null;
|
|
168
216
|
this._panel?.remove();
|
|
169
217
|
this._formatHint?.remove();
|
|
170
|
-
this._button = null;
|
|
171
218
|
this._panel = null;
|
|
172
219
|
this._calendar = null;
|
|
173
220
|
this._formatHint = null;
|
|
@@ -248,6 +295,16 @@ export class DatePickerElement extends HTMLElement {
|
|
|
248
295
|
else this.removeAttribute("locale");
|
|
249
296
|
}
|
|
250
297
|
|
|
298
|
+
/** @public Visible month-name style forwarded to the popup calendar (`long` | `short`); anything else falls back to `long`. */
|
|
299
|
+
get monthFormat() {
|
|
300
|
+
return this.getAttribute("month-format") === "short" ? "short" : "long";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
set monthFormat(value) {
|
|
304
|
+
if (value === "short") this.setAttribute("month-format", "short");
|
|
305
|
+
else this.removeAttribute("month-format");
|
|
306
|
+
}
|
|
307
|
+
|
|
251
308
|
/** @public */
|
|
252
309
|
get min() {
|
|
253
310
|
const value = this.getAttribute("min") || "";
|
|
@@ -342,6 +399,15 @@ export class DatePickerElement extends HTMLElement {
|
|
|
342
399
|
if (this._connected) void this.validate();
|
|
343
400
|
}
|
|
344
401
|
|
|
402
|
+
/** @public JS-only coordinate-space policy: "viewport" by default, "document" as an explicit opt-in; a change made while open applies to the next opening only. @returns {"viewport" | "document"} */
|
|
403
|
+
get coordinateSpace() {
|
|
404
|
+
return this._coordinateSpace;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
set coordinateSpace(value) {
|
|
408
|
+
this._coordinateSpace = value === "document" ? "document" : "viewport";
|
|
409
|
+
}
|
|
410
|
+
|
|
345
411
|
_adapter() {
|
|
346
412
|
return this._field?.adapter ?? this._fields?.[0]?.adapter ?? createDateAdapter(this.locale);
|
|
347
413
|
}
|
|
@@ -356,16 +422,22 @@ export class DatePickerElement extends HTMLElement {
|
|
|
356
422
|
// The hidden canonical field owns submission; dynamic name/form/disabled
|
|
357
423
|
// movement lives in the field controller.
|
|
358
424
|
this._field?.syncInputState();
|
|
359
|
-
|
|
425
|
+
const button = this._buttons[0]?.button;
|
|
426
|
+
if (button) button.disabled = input.disabled || input.readOnly;
|
|
360
427
|
if (this._open && (input.disabled || input.readOnly)) this.hide(false);
|
|
361
428
|
}
|
|
362
429
|
|
|
363
430
|
_syncRangeFields() {
|
|
364
431
|
if (!this._fields) return;
|
|
365
432
|
for (const field of this._fields) field.syncInputState();
|
|
433
|
+
// Each trigger follows its own bound: a disabled end never disables the
|
|
434
|
+
// start trigger.
|
|
435
|
+
for (const { button, endpoint } of this._buttons) {
|
|
436
|
+
const index = endpoint === "end" ? 1 : 0;
|
|
437
|
+
const field = this._fields[index];
|
|
438
|
+
button.disabled = Boolean(field?.input.disabled || field?.input.readOnly);
|
|
439
|
+
}
|
|
366
440
|
const active = this._range?.activeEndpoint === "end" ? 1 : 0;
|
|
367
|
-
const disabled = this._fields.every((field) => field.input.disabled || field.input.readOnly);
|
|
368
|
-
if (this._button) this._button.disabled = disabled;
|
|
369
441
|
if (this._open && (this._fields[active]?.input.disabled || this._fields[active]?.input.readOnly)) {
|
|
370
442
|
this.hide(false);
|
|
371
443
|
}
|
|
@@ -386,20 +458,52 @@ export class DatePickerElement extends HTMLElement {
|
|
|
386
458
|
}
|
|
387
459
|
}
|
|
388
460
|
|
|
389
|
-
/**
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
461
|
+
/**
|
|
462
|
+
* Create one calendar trigger. It is a real button overlaid inside the
|
|
463
|
+
* field's own box (see the CSS): the field reserves the room, the trigger
|
|
464
|
+
* stays transparent, and the field's focus ring wraps both.
|
|
465
|
+
* @param {"" | "start" | "end"} endpoint
|
|
466
|
+
* @returns {HTMLButtonElement}
|
|
467
|
+
*/
|
|
468
|
+
_createTrigger(endpoint) {
|
|
395
469
|
const button = document.createElement("button");
|
|
396
470
|
button.type = "button";
|
|
397
471
|
button.className = "dp-picker-button";
|
|
398
472
|
button.setAttribute("aria-haspopup", "dialog");
|
|
399
473
|
button.setAttribute("aria-expanded", "false");
|
|
400
|
-
button.setAttribute("aria-controls",
|
|
474
|
+
button.setAttribute("aria-controls", `${this._id}-panel`);
|
|
475
|
+
if (endpoint) button.dataset.endpoint = endpoint;
|
|
401
476
|
button.innerHTML =
|
|
402
477
|
'<span aria-hidden="true"><svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><rect x="1.75" y="3" width="12.5" height="11" rx="1.75"/><path d="M1.75 6.75h12.5"/><path d="M5.25 1.75v2.5M10.75 1.75v2.5"/></svg></span>';
|
|
478
|
+
return button;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Build the popup once and one trigger per field. Each trigger is inserted
|
|
483
|
+
* immediately after its own field so DOM order follows visual/tab order
|
|
484
|
+
* (`date -> its trigger -> next control`). The hidden ISO field may sit
|
|
485
|
+
* between the two; it is not focusable.
|
|
486
|
+
*/
|
|
487
|
+
_build() {
|
|
488
|
+
const ranges = this._rangeMode();
|
|
489
|
+
/** @type {[HTMLInputElement, "" | "start" | "end"][]} */
|
|
490
|
+
const targets = [];
|
|
491
|
+
if (ranges) {
|
|
492
|
+
for (const [index, field] of (this._fields || []).entries()) {
|
|
493
|
+
targets.push([field.input, index === 1 ? "end" : "start"]);
|
|
494
|
+
}
|
|
495
|
+
} else if (this._input) {
|
|
496
|
+
targets.push([this._input, ""]);
|
|
497
|
+
}
|
|
498
|
+
if (!targets.length) return;
|
|
499
|
+
const panelId = `${this._id}-panel`;
|
|
500
|
+
const hintId = `${this._id}-format`;
|
|
501
|
+
|
|
502
|
+
this._buttons = targets.map(([input, endpoint]) => {
|
|
503
|
+
const button = this._createTrigger(endpoint);
|
|
504
|
+
input.insertAdjacentElement("afterend", button);
|
|
505
|
+
return { button, endpoint };
|
|
506
|
+
});
|
|
403
507
|
|
|
404
508
|
const panel = document.createElement("div");
|
|
405
509
|
panel.id = panelId;
|
|
@@ -417,23 +521,21 @@ export class DatePickerElement extends HTMLElement {
|
|
|
417
521
|
hint.id = hintId;
|
|
418
522
|
hint.className = "dp-visually-hidden";
|
|
419
523
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
panel.insertAdjacentElement("afterend", hint);
|
|
524
|
+
// Out of flow (popover / visually hidden): placement is irrelevant.
|
|
525
|
+
this.append(panel, hint);
|
|
423
526
|
|
|
424
|
-
this._button = button;
|
|
425
527
|
this._panel = panel;
|
|
426
528
|
this._calendar = calendar;
|
|
427
529
|
this._formatHint = hint;
|
|
428
530
|
|
|
429
|
-
if (!this.
|
|
430
|
-
this._originalDescribedBy =
|
|
531
|
+
if (!ranges && this._input) {
|
|
532
|
+
this._originalDescribedBy = this._input.getAttribute("aria-describedby") || "";
|
|
431
533
|
const describedBy = [this._originalDescribedBy, hintId].filter(Boolean).join(" ");
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
534
|
+
this._input.setAttribute("aria-describedby", describedBy);
|
|
535
|
+
this._input.setAttribute("role", "combobox");
|
|
536
|
+
this._input.setAttribute("aria-haspopup", "dialog");
|
|
537
|
+
this._input.setAttribute("aria-expanded", "false");
|
|
538
|
+
this._input.setAttribute("aria-controls", panelId);
|
|
437
539
|
}
|
|
438
540
|
this._refreshLocale();
|
|
439
541
|
this._refreshButtonLabel();
|
|
@@ -441,7 +543,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
441
543
|
|
|
442
544
|
_bind() {
|
|
443
545
|
const input = this._input;
|
|
444
|
-
const button = this.
|
|
546
|
+
const button = this._buttons[0]?.button;
|
|
445
547
|
const calendar = this._calendar;
|
|
446
548
|
if (!input || !button || !calendar) return;
|
|
447
549
|
const controller = new AbortController();
|
|
@@ -479,6 +581,12 @@ export class DatePickerElement extends HTMLElement {
|
|
|
479
581
|
},
|
|
480
582
|
{ signal },
|
|
481
583
|
);
|
|
584
|
+
for (const [index, timeInput] of this._timeFields.entries()) {
|
|
585
|
+
if (!timeInput) continue;
|
|
586
|
+
const endpoint = index === 1 ? "end" : "start";
|
|
587
|
+
timeInput.addEventListener("input", () => this._revalidateTimeOrder(endpoint), { signal });
|
|
588
|
+
timeInput.addEventListener("change", () => this._revalidateTimeOrder(endpoint), { signal });
|
|
589
|
+
}
|
|
482
590
|
button.addEventListener(
|
|
483
591
|
"click",
|
|
484
592
|
(event) => {
|
|
@@ -494,10 +602,11 @@ export class DatePickerElement extends HTMLElement {
|
|
|
494
602
|
this.hide(false);
|
|
495
603
|
return;
|
|
496
604
|
}
|
|
497
|
-
this.show();
|
|
605
|
+
this.show({ returnFocus: button });
|
|
498
606
|
},
|
|
499
607
|
{ signal },
|
|
500
608
|
);
|
|
609
|
+
button.addEventListener("keydown", (event) => this._onTriggerKeyDown("", event), { signal });
|
|
501
610
|
calendar.addEventListener(
|
|
502
611
|
"dateactivate",
|
|
503
612
|
(event) => {
|
|
@@ -512,7 +621,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
512
621
|
this._field?.dirty();
|
|
513
622
|
this._setValue(date, { emit: true, format: true });
|
|
514
623
|
this.hide(false);
|
|
515
|
-
this.
|
|
624
|
+
this._restoreFocus();
|
|
516
625
|
});
|
|
517
626
|
},
|
|
518
627
|
{ signal },
|
|
@@ -545,9 +654,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
545
654
|
}
|
|
546
655
|
|
|
547
656
|
_bindRange() {
|
|
548
|
-
const button = this._button;
|
|
549
657
|
const calendar = this._calendar;
|
|
550
|
-
if (!this._fields || !
|
|
658
|
+
if (!this._fields || !this._buttons.length || !calendar) return;
|
|
551
659
|
const controller = new AbortController();
|
|
552
660
|
this._controller = controller;
|
|
553
661
|
const { signal } = controller;
|
|
@@ -564,21 +672,16 @@ export class DatePickerElement extends HTMLElement {
|
|
|
564
672
|
);
|
|
565
673
|
field.input.addEventListener("keydown", (event) => this._onFieldKeyDown(endpoint, event), { signal });
|
|
566
674
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
(
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
578
|
-
this.show();
|
|
579
|
-
},
|
|
580
|
-
{ signal },
|
|
581
|
-
);
|
|
675
|
+
for (const entry of this._buttons) {
|
|
676
|
+
const endpoint = entry.endpoint;
|
|
677
|
+
if (endpoint === "") continue;
|
|
678
|
+
entry.button.addEventListener("click", (event) => this._onRangeTriggerClick(endpoint, event), {
|
|
679
|
+
signal,
|
|
680
|
+
});
|
|
681
|
+
entry.button.addEventListener("keydown", (event) => this._onTriggerKeyDown(endpoint, event), {
|
|
682
|
+
signal,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
582
685
|
// Record grid-activation intent synchronously (click or Enter/Space). The
|
|
583
686
|
// picker only commits a dateactivate whose generation still matches, so a
|
|
584
687
|
// response that resolves after the active endpoint changed (or after a
|
|
@@ -649,9 +752,72 @@ export class DatePickerElement extends HTMLElement {
|
|
|
649
752
|
this.addEventListener("keydown", (event) => this._onEscape(event), { signal });
|
|
650
753
|
}
|
|
651
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Point the shared calendar at one range bound and invalidate any pending
|
|
757
|
+
* activation, so a late response can never commit on the wrong bound.
|
|
758
|
+
* @param {"start" | "end"} endpoint
|
|
759
|
+
*/
|
|
760
|
+
_activateRangeEndpoint(endpoint) {
|
|
761
|
+
const calendar = this._calendar;
|
|
762
|
+
if (!this._range || !calendar) return;
|
|
763
|
+
this._range.focus(endpoint);
|
|
764
|
+
this._rangeCommitId++;
|
|
765
|
+
this._pendingIntents.clear();
|
|
766
|
+
this._lastFocusEndpoint = endpoint;
|
|
767
|
+
const target = this._boundCanonical(endpoint) || todayISO();
|
|
768
|
+
calendar.display = monthKey(target);
|
|
769
|
+
calendar.focusedDate = target;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Range has one trigger per bound. A press on the other bound's trigger is a
|
|
774
|
+
* local switch, not a toggle: it repoints the single open popup instead of
|
|
775
|
+
* closing it. Only a press on the already-active bound toggles shut.
|
|
776
|
+
* @param {"start" | "end"} endpoint
|
|
777
|
+
* @param {MouseEvent} event
|
|
778
|
+
*/
|
|
779
|
+
_onRangeTriggerClick(endpoint, event) {
|
|
780
|
+
if (!this._open) {
|
|
781
|
+
this.show({ endpoint });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (event.detail === 0) {
|
|
785
|
+
// Keyboard activation of an already-open picker: switch the bound, then
|
|
786
|
+
// move into the grid. Escape keeps closing it.
|
|
787
|
+
this._activateRangeEndpoint(endpoint);
|
|
788
|
+
setTimeout(() => this._calendar?.focusGrid(), 0);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (endpoint !== this._range?.activeEndpoint) {
|
|
792
|
+
this._activateRangeEndpoint(endpoint);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
this.hide(false);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* ArrowDown on a trigger opens the popup and drops into the grid, like the
|
|
800
|
+
* field itself (Enter/Space go through the click path). Needed because focus
|
|
801
|
+
* now returns to the trigger that opened the popover.
|
|
802
|
+
* @param {"" | "start" | "end"} endpoint
|
|
803
|
+
* @param {KeyboardEvent} event
|
|
804
|
+
*/
|
|
805
|
+
_onTriggerKeyDown(endpoint, event) {
|
|
806
|
+
if (event.key !== "ArrowDown") return;
|
|
807
|
+
event.preventDefault();
|
|
808
|
+
if (this._open) {
|
|
809
|
+
if (this._rangeMode() && endpoint) this._activateRangeEndpoint(endpoint);
|
|
810
|
+
// Chromium drops script-initiated focus changes during keydown dispatch.
|
|
811
|
+
setTimeout(() => this._calendar?.focusGrid(), 0);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (this._rangeMode()) this.show({ endpoint: endpoint || undefined });
|
|
815
|
+
else this.show({ returnFocus: this._buttons[0]?.button });
|
|
816
|
+
}
|
|
817
|
+
|
|
652
818
|
/** @param {"" | "start" | "end"} endpoint @param {KeyboardEvent} event */
|
|
653
819
|
_onFieldKeyDown(endpoint, event) {
|
|
654
|
-
if (event.key === "ArrowDown"
|
|
820
|
+
if (event.key === "ArrowDown") {
|
|
655
821
|
event.preventDefault();
|
|
656
822
|
if (this._rangeMode() && endpoint) {
|
|
657
823
|
this._lastFocusEndpoint = endpoint;
|
|
@@ -702,6 +868,8 @@ export class DatePickerElement extends HTMLElement {
|
|
|
702
868
|
const cell = event.target.closest(".dp-day[data-date]");
|
|
703
869
|
const date = cell?.getAttribute("data-date") || "";
|
|
704
870
|
if (!isDate(date)) return;
|
|
871
|
+
// Each activation supersedes every earlier pending one.
|
|
872
|
+
this._pendingIntents.clear();
|
|
705
873
|
this._pendingIntents.set(date, ++this._rangeCommitId);
|
|
706
874
|
}
|
|
707
875
|
|
|
@@ -721,6 +889,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
721
889
|
calendar.locale = this.locale;
|
|
722
890
|
calendar.min = this.min;
|
|
723
891
|
calendar.max = this.max;
|
|
892
|
+
calendar.monthFormat = this.monthFormat;
|
|
724
893
|
calendar.messages = this._messages;
|
|
725
894
|
calendar.source = this._source;
|
|
726
895
|
calendar.dateState = this._dateState;
|
|
@@ -761,25 +930,29 @@ export class DatePickerElement extends HTMLElement {
|
|
|
761
930
|
}
|
|
762
931
|
|
|
763
932
|
_refreshButtonLabel() {
|
|
764
|
-
|
|
933
|
+
const single = this._buttons[0]?.button;
|
|
934
|
+
if (!single) return;
|
|
765
935
|
if (this._rangeMode()) {
|
|
766
936
|
this._refreshRangeButtonLabel();
|
|
767
937
|
return;
|
|
768
938
|
}
|
|
769
939
|
const prefix = this._value ? this._messages.changeDate : this._messages.chooseDate;
|
|
770
940
|
const suffix = this._value ? `, ${formatLongDate(this._value, this.locale)}` : "";
|
|
771
|
-
|
|
941
|
+
single.setAttribute("aria-label", `${prefix}${suffix}`);
|
|
772
942
|
}
|
|
773
943
|
|
|
944
|
+
/** Each bound has its own trigger, so each needs its own accessible name: the
|
|
945
|
+
* bound label is what tells two otherwise identical triggers apart. */
|
|
774
946
|
_refreshRangeButtonLabel() {
|
|
775
|
-
if (!this.
|
|
947
|
+
if (!this._range) return;
|
|
776
948
|
const { start, end } = this._range;
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
? `, ${formatLongDate(
|
|
781
|
-
|
|
782
|
-
|
|
949
|
+
for (const { button, endpoint } of this._buttons) {
|
|
950
|
+
const bound = endpoint === "end" ? end : start;
|
|
951
|
+
const prefix = bound ? this._messages.changeDate : this._messages.chooseDate;
|
|
952
|
+
const suffix = bound ? `, ${formatLongDate(bound, this.locale)}` : "";
|
|
953
|
+
const boundLabel = endpoint === "end" ? this._messages.rangeEnd : this._messages.rangeStart;
|
|
954
|
+
button.setAttribute("aria-label", `${prefix}${suffix}, ${boundLabel}`);
|
|
955
|
+
}
|
|
783
956
|
}
|
|
784
957
|
|
|
785
958
|
/** @param {string} value */
|
|
@@ -855,7 +1028,9 @@ export class DatePickerElement extends HTMLElement {
|
|
|
855
1028
|
this._orderTags.delete(index);
|
|
856
1029
|
return false;
|
|
857
1030
|
}
|
|
858
|
-
|
|
1031
|
+
// Typed commits keep native input/change only; synthetic events are
|
|
1032
|
+
// reserved for calendar-driven changes (no native input event occurred).
|
|
1033
|
+
this._setBound(which, result.value || "", { emit: false, user: true, format: result.status === "ok" });
|
|
859
1034
|
return true;
|
|
860
1035
|
}
|
|
861
1036
|
|
|
@@ -959,6 +1134,47 @@ export class DatePickerElement extends HTMLElement {
|
|
|
959
1134
|
}
|
|
960
1135
|
}
|
|
961
1136
|
|
|
1137
|
+
/**
|
|
1138
|
+
* Single-mode from/to order on the shared date: `start <= end`.
|
|
1139
|
+
* The order error is attributed to the bound that was just modified; the
|
|
1140
|
+
* other bound only loses a stale order error, never its native validity.
|
|
1141
|
+
* Missing, empty or disabled times fall back to no order constraint: times
|
|
1142
|
+
* are never implicitly required. Equality stays valid (duration rules are
|
|
1143
|
+
* application-owned).
|
|
1144
|
+
* @param {"start" | "end"} which
|
|
1145
|
+
*/
|
|
1146
|
+
_revalidateTimeOrder(which) {
|
|
1147
|
+
const [startInput, endInput] = this._timeFields;
|
|
1148
|
+
/** @type {["start", "end"]} */
|
|
1149
|
+
const bounds = ["start", "end"];
|
|
1150
|
+
const inputs = [startInput, endInput];
|
|
1151
|
+
const startValue = startInput && !startInput.disabled ? startInput.value.trim() : "";
|
|
1152
|
+
const endValue = endInput && !endInput.disabled ? endInput.value.trim() : "";
|
|
1153
|
+
const inverted =
|
|
1154
|
+
Boolean(startInput && endInput && startValue && endValue) &&
|
|
1155
|
+
isTime(startValue) &&
|
|
1156
|
+
isTime(endValue) &&
|
|
1157
|
+
compareTimes(endValue, startValue) < 0;
|
|
1158
|
+
for (const [index, bound] of bounds.entries()) {
|
|
1159
|
+
const input = inputs[index];
|
|
1160
|
+
if (!input) continue;
|
|
1161
|
+
const message = bound === "end" ? this._messages.rangeOrderEnd : this._messages.rangeOrderStart;
|
|
1162
|
+
const shouldHaveOrder = inverted && bound === which;
|
|
1163
|
+
if (shouldHaveOrder) {
|
|
1164
|
+
input.setCustomValidity(message);
|
|
1165
|
+
this._timeOrderOwned.add(input);
|
|
1166
|
+
} else if (this._timeOrderOwned.has(input)) {
|
|
1167
|
+
input.setCustomValidity("");
|
|
1168
|
+
this._timeOrderOwned.delete(input);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
_clearTimeOrderValidity() {
|
|
1174
|
+
for (const input of this._timeOrderOwned) input.setCustomValidity("");
|
|
1175
|
+
this._timeOrderOwned.clear();
|
|
1176
|
+
}
|
|
1177
|
+
|
|
962
1178
|
_restoreDefault() {
|
|
963
1179
|
const field = this._field;
|
|
964
1180
|
if (!field) return;
|
|
@@ -1001,13 +1217,19 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1001
1217
|
}, 0);
|
|
1002
1218
|
}
|
|
1003
1219
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1220
|
+
/**
|
|
1221
|
+
* Restore focus to the control that opened the popover (single mode). focus()
|
|
1222
|
+
* must not run synchronously from a keydown handler (Chromium drops focus
|
|
1223
|
+
* changes there) and must not re-open the popover under the default
|
|
1224
|
+
* open-on-focus. Deferring keeps _suppressFocusOpen active for the call.
|
|
1225
|
+
*/
|
|
1226
|
+
_restoreFocus() {
|
|
1227
|
+
const target = this._returnFocus;
|
|
1228
|
+
this._returnFocus = null;
|
|
1229
|
+
if (!(target instanceof HTMLElement) || !target.isConnected || target.matches(":disabled")) return;
|
|
1008
1230
|
this._suppressFocusOpen = true;
|
|
1009
1231
|
setTimeout(() => {
|
|
1010
|
-
|
|
1232
|
+
target.focus();
|
|
1011
1233
|
this._suppressFocusOpen = false;
|
|
1012
1234
|
}, 0);
|
|
1013
1235
|
}
|
|
@@ -1051,40 +1273,50 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1051
1273
|
if (!input) return true;
|
|
1052
1274
|
if (!input.value.trim()) {
|
|
1053
1275
|
input.setCustomValidity("");
|
|
1054
|
-
|
|
1276
|
+
} else {
|
|
1277
|
+
await this._commitText(false, { force: true });
|
|
1055
1278
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1279
|
+
// Same start-then-end order as the range path, so a from/to inversion
|
|
1280
|
+
// ends reported on "end". A programmatic time change resyncs here too.
|
|
1281
|
+
this._revalidateTimeOrder("start");
|
|
1282
|
+
this._revalidateTimeOrder("end");
|
|
1283
|
+
const timesValid = this._timeFields.every((timeInput) => timeInput?.checkValidity() ?? true);
|
|
1284
|
+
return input.checkValidity() && timesValid;
|
|
1058
1285
|
}
|
|
1059
1286
|
|
|
1060
|
-
/** @public @param {{moveFocus?:boolean}} [options] */
|
|
1287
|
+
/** @public @param {{moveFocus?:boolean, endpoint?:"start"|"end", returnFocus?:HTMLElement}} [options] */
|
|
1061
1288
|
show(options = {}) {
|
|
1062
1289
|
const panel = this._panel;
|
|
1063
1290
|
const calendar = this._calendar;
|
|
1064
|
-
|
|
1065
|
-
if (!panel || !calendar || !button || this._open) return;
|
|
1291
|
+
if (!panel || !calendar || !this._buttons.length || this._open) return;
|
|
1066
1292
|
if (this._rangeMode()) {
|
|
1067
|
-
const endpoint = this._resolveRangeEndpoint();
|
|
1293
|
+
const endpoint = options.endpoint || this._resolveRangeEndpoint();
|
|
1068
1294
|
if (!endpoint) return;
|
|
1069
|
-
this.
|
|
1070
|
-
this._rangeCommitId++;
|
|
1071
|
-
this._pendingIntents.clear();
|
|
1072
|
-
const target = this._boundCanonical(endpoint) || todayISO();
|
|
1073
|
-
calendar.display = monthKey(target);
|
|
1074
|
-
calendar.focusedDate = target;
|
|
1075
|
-
this._lastFocusEndpoint = endpoint;
|
|
1295
|
+
this._activateRangeEndpoint(endpoint);
|
|
1076
1296
|
} else {
|
|
1077
1297
|
const input = this._input;
|
|
1078
1298
|
if (!input || input.disabled || input.readOnly) return;
|
|
1079
1299
|
const target = this._value || this._adapter().parse(input.value) || todayISO();
|
|
1080
1300
|
calendar.display = monthKey(target);
|
|
1081
1301
|
calendar.focusedDate = target;
|
|
1302
|
+
// Popover convention: closing returns focus to the control that opened.
|
|
1303
|
+
this._returnFocus = options.returnFocus instanceof HTMLElement ? options.returnFocus : input;
|
|
1082
1304
|
}
|
|
1083
1305
|
panel.showPopover();
|
|
1084
1306
|
this._open = true;
|
|
1085
1307
|
this._setExpanded(true);
|
|
1308
|
+
// Freeze the explicitly configured coordinate space once per opening: a
|
|
1309
|
+
// change made while open takes effect on the next opening, never the
|
|
1310
|
+
// current one.
|
|
1311
|
+
const coordinateSpace = this.coordinateSpace;
|
|
1312
|
+
panel.style.position = coordinateSpace === "document" ? "absolute" : "fixed";
|
|
1086
1313
|
const position = () =>
|
|
1087
|
-
reposition(this, panel, {
|
|
1314
|
+
reposition(this, panel, {
|
|
1315
|
+
placement: "bottom-start",
|
|
1316
|
+
distance: 4,
|
|
1317
|
+
shiftPadding: 8,
|
|
1318
|
+
coordinateSpace,
|
|
1319
|
+
});
|
|
1088
1320
|
position();
|
|
1089
1321
|
this._stopTracking = autoUpdate(this, panel, position);
|
|
1090
1322
|
if (options.moveFocus !== false) queueMicrotask(() => calendar.focusGrid());
|
|
@@ -1095,7 +1327,7 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1095
1327
|
_setExpanded(expanded) {
|
|
1096
1328
|
const value = expanded ? "true" : "false";
|
|
1097
1329
|
this._input?.setAttribute("aria-expanded", value);
|
|
1098
|
-
this.
|
|
1330
|
+
for (const { button } of this._buttons) button.setAttribute("aria-expanded", value);
|
|
1099
1331
|
for (const field of this._fields || []) field.input.setAttribute("aria-expanded", value);
|
|
1100
1332
|
}
|
|
1101
1333
|
|
|
@@ -1115,10 +1347,12 @@ export class DatePickerElement extends HTMLElement {
|
|
|
1115
1347
|
this._pendingIntents.clear();
|
|
1116
1348
|
if (restoreFocus) {
|
|
1117
1349
|
if (this._rangeMode()) {
|
|
1350
|
+
// Range owns a workflow: after a first pick the active bound moved, so
|
|
1351
|
+
// focus follows the bound rather than the original trigger.
|
|
1118
1352
|
const endpoint = this._restoreFocusEndpoint();
|
|
1119
1353
|
if (endpoint) this._focusField(endpoint);
|
|
1120
1354
|
} else {
|
|
1121
|
-
this.
|
|
1355
|
+
this._restoreFocus();
|
|
1122
1356
|
}
|
|
1123
1357
|
}
|
|
1124
1358
|
this.dispatchEvent(new Event("close", { bubbles: true }));
|