@lekoala/date-picker 0.3.0 → 0.5.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 +281 -263
- package/custom-elements.json +30 -0
- package/dist/date-picker.css +51 -46
- package/dist/date-picker.js +685 -204
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +13 -13
- package/dist/date-picker.standalone.min.js +65 -60
- package/dist/types/control-classes.d.ts +30 -0
- package/dist/types/control-classes.d.ts.map +1 -0
- package/dist/types/date-calendar.d.ts +49 -3
- package/dist/types/date-calendar.d.ts.map +1 -1
- package/dist/types/date-field.d.ts +12 -4
- package/dist/types/date-field.d.ts.map +1 -1
- package/dist/types/date-picker.d.ts +106 -9
- package/dist/types/date-picker.d.ts.map +1 -1
- package/dist/types/date-range.d.ts +102 -20
- package/dist/types/date-range.d.ts.map +1 -1
- package/dist/types/date.d.ts +14 -2
- package/dist/types/date.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/intl.d.ts.map +1 -1
- package/dist/types/source.d.ts +1 -0
- package/dist/types/source.d.ts.map +1 -1
- package/docs/ACCESSIBILITY.md +6 -2
- package/docs/API.md +430 -358
- package/docs/DECISIONS.md +68 -49
- package/docs/MIGRATION-FLATPICKR.md +1 -1
- package/docs/TESTING.md +1 -1
- package/docs/USE_CASES.md +156 -141
- package/package.json +8 -8
- package/src/control-classes.js +50 -0
- package/src/date-calendar.js +188 -54
- package/src/date-field.js +9 -5
- package/src/date-picker.css +51 -46
- package/src/date-picker.js +392 -83
- package/src/date-range.js +184 -30
- package/src/date.js +57 -3
- package/src/index.js +1 -0
- package/src/intl.js +52 -17
- package/src/source.js +14 -4
package/src/date-calendar.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { CalendarModel } from "./calendar-model.js";
|
|
2
|
+
import { getDefaultControlClasses, resolveControlClasses, sameControlClasses } from "./control-classes.js";
|
|
2
3
|
import {
|
|
3
4
|
addDays,
|
|
4
5
|
clampDate,
|
|
6
|
+
clampDisplayMonth,
|
|
5
7
|
compareDates,
|
|
8
|
+
endOfMonth,
|
|
6
9
|
getMonthWeeks,
|
|
7
10
|
isDate,
|
|
8
11
|
isoWeekNumber,
|
|
9
12
|
monthKey,
|
|
10
|
-
shiftMonth,
|
|
11
13
|
todayISO,
|
|
12
14
|
} from "./date.js";
|
|
13
15
|
import { normalizeRange, rangePosition } from "./date-range.js";
|
|
@@ -46,20 +48,43 @@ function yearOf(month) {
|
|
|
46
48
|
return Number(month.slice(0, 4));
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Step a `YYYY-MM` key by ±1, or `null` when the step leaves the canonical
|
|
53
|
+
* 0001-01..9999-12 window. Pure arithmetic, so an out-of-range intermediate
|
|
54
|
+
* date is never constructed and the calendar edges cannot throw.
|
|
55
|
+
* @param {string} month
|
|
56
|
+
* @param {number} amount
|
|
57
|
+
* @returns {string | null}
|
|
58
|
+
*/
|
|
59
|
+
function stepMonthKey(month, amount) {
|
|
60
|
+
const [year, index] = month.split("-").map(Number);
|
|
61
|
+
const absolute = year * 12 + (index - 1) + amount;
|
|
62
|
+
const stepYear = Math.floor(absolute / 12);
|
|
63
|
+
const stepMonth = (((absolute % 12) + 12) % 12) + 1;
|
|
64
|
+
if (stepYear < 1 || stepYear > 9999) return null;
|
|
65
|
+
return `${String(stepYear).padStart(4, "0")}-${String(stepMonth).padStart(2, "0")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Band attributes for one day position. Markup and in-place band updates read
|
|
70
|
+
* the same map, so the two paths cannot disagree about a cell's state.
|
|
71
|
+
* @param {"" | "start" | "in" | "end" | "single"} position
|
|
72
|
+
* @returns {Record<string, boolean>}
|
|
73
|
+
*/
|
|
74
|
+
function rangeAttributeMap(position) {
|
|
75
|
+
return {
|
|
76
|
+
"data-range-start": position === "start" || position === "single",
|
|
77
|
+
"data-range-end": position === "end" || position === "single",
|
|
78
|
+
"data-in-range": position === "in",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
49
82
|
/** @param {"" | "start" | "in" | "end" | "single"} position */
|
|
50
83
|
function rangeAttributes(position) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return ' data-range-start="true"';
|
|
56
|
-
case "end":
|
|
57
|
-
return ' data-range-end="true"';
|
|
58
|
-
case "in":
|
|
59
|
-
return ' data-in-range="true"';
|
|
60
|
-
default:
|
|
61
|
-
return "";
|
|
62
|
-
}
|
|
84
|
+
return Object.entries(rangeAttributeMap(position))
|
|
85
|
+
.filter(([, on]) => on)
|
|
86
|
+
.map(([name]) => ` ${name}="true"`)
|
|
87
|
+
.join("");
|
|
63
88
|
}
|
|
64
89
|
|
|
65
90
|
/** @param {"" | "start" | "in" | "end" | "single"} position */
|
|
@@ -125,7 +150,16 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
125
150
|
this._isDateDisabled = null;
|
|
126
151
|
/** @type {DateRange} */
|
|
127
152
|
this._highlightedRange = { start: "", end: "" };
|
|
153
|
+
// Bounds and preview flag the cells were last painted with. The preview
|
|
154
|
+
// flag changes the accessible names (a proposal drops the committed
|
|
155
|
+
// range-start/range-end suffix), so a repaint is due when either changes.
|
|
156
|
+
/** @type {DateRange} */
|
|
157
|
+
this._paintedRange = { start: "", end: "" };
|
|
158
|
+
this._paintedPreview = false;
|
|
159
|
+
/** @type {Map<string, DateState>} States of the current rendered grid. */
|
|
160
|
+
this._renderedStates = new Map();
|
|
128
161
|
this._messages = getDefaultMessages();
|
|
162
|
+
this._controlClasses = getDefaultControlClasses();
|
|
129
163
|
this._onClick = this._onClick.bind(this);
|
|
130
164
|
this._onChange = this._onChange.bind(this);
|
|
131
165
|
this._onKeyDown = this._onKeyDown.bind(this);
|
|
@@ -262,9 +296,17 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
262
296
|
|
|
263
297
|
set highlightedRange(value) {
|
|
264
298
|
const next = normalizeRange(value);
|
|
265
|
-
|
|
299
|
+
const preview = this.hasAttribute("data-range-preview");
|
|
300
|
+
// Repaint when the preview mode changed even if the bounds are identical:
|
|
301
|
+
// entering/leaving a preview rewrites every cell's accessible name.
|
|
302
|
+
if (
|
|
303
|
+
next.start === this._paintedRange.start &&
|
|
304
|
+
next.end === this._paintedRange.end &&
|
|
305
|
+
preview === this._paintedPreview
|
|
306
|
+
)
|
|
307
|
+
return;
|
|
266
308
|
this._highlightedRange = next;
|
|
267
|
-
if (this._connected) this.
|
|
309
|
+
if (this._connected) this._updateRangeDom();
|
|
268
310
|
}
|
|
269
311
|
|
|
270
312
|
/** @public */
|
|
@@ -285,6 +327,23 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
285
327
|
this.toggleAttribute("show-week-numbers", Boolean(value));
|
|
286
328
|
}
|
|
287
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Author classes for header controls; merged with global defaults.
|
|
332
|
+
* @public
|
|
333
|
+
* @returns {import("./control-classes.js").ControlClasses}
|
|
334
|
+
*/
|
|
335
|
+
get controlClasses() {
|
|
336
|
+
return { ...this._controlClasses };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** @param {Partial<import("./control-classes.js").ControlClasses> | null} value */
|
|
340
|
+
set controlClasses(value) {
|
|
341
|
+
const classes = resolveControlClasses(value);
|
|
342
|
+
if (sameControlClasses(classes, this._controlClasses)) return;
|
|
343
|
+
this._controlClasses = classes;
|
|
344
|
+
if (this._connected) this.render();
|
|
345
|
+
}
|
|
346
|
+
|
|
288
347
|
/** @public */
|
|
289
348
|
get messages() {
|
|
290
349
|
return this._messages;
|
|
@@ -343,7 +402,7 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
343
402
|
const attrDisplay = this.getAttribute("display") || "";
|
|
344
403
|
if (attrDisplay) {
|
|
345
404
|
try {
|
|
346
|
-
this._model.setDisplay(attrDisplay);
|
|
405
|
+
this._model.setDisplay(clampDisplayMonth(attrDisplay, firstDay));
|
|
347
406
|
} catch {
|
|
348
407
|
// Invalid author markup falls back to the current model display.
|
|
349
408
|
}
|
|
@@ -368,20 +427,24 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
368
427
|
|
|
369
428
|
/** @param {string} value */
|
|
370
429
|
_clamp(value) {
|
|
371
|
-
|
|
430
|
+
const bounded = clampDate(value, this.min, this.max);
|
|
431
|
+
const month = monthKey(bounded);
|
|
432
|
+
const safeMonth = clampDisplayMonth(month, this.firstDay);
|
|
433
|
+
if (safeMonth === month) return bounded;
|
|
434
|
+
// The renderable window excludes this month (a calendar edge): keep the
|
|
435
|
+
// day-of-month where the target month has it, else fall back to its last
|
|
436
|
+
// day, so the focused date always exists in the rendered grid.
|
|
437
|
+
const day = Number(bounded.slice(8, 10)) || 1;
|
|
438
|
+
const candidate = addDays(`${safeMonth}-01`, day - 1);
|
|
439
|
+
const last = endOfMonth(safeMonth);
|
|
440
|
+
return compareDates(candidate, last) > 0 ? last : candidate;
|
|
372
441
|
}
|
|
373
442
|
|
|
374
443
|
_weeks(display = this.display) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const week = [];
|
|
380
|
-
for (let i = 1; i <= 7; i++) week.push(addDays(cursor, i));
|
|
381
|
-
weeks.push(week);
|
|
382
|
-
cursor = week[6];
|
|
383
|
-
}
|
|
384
|
-
return weeks;
|
|
444
|
+
return getMonthWeeks(clampDisplayMonth(display, this.firstDay), {
|
|
445
|
+
firstDay: this.firstDay,
|
|
446
|
+
fixedWeeks: this.fixedWeeks,
|
|
447
|
+
});
|
|
385
448
|
}
|
|
386
449
|
|
|
387
450
|
/** @param {HTMLElement} element @returns {string} */
|
|
@@ -491,7 +554,7 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
491
554
|
/** @param {string} display @param {{emit?:boolean}} [options] */
|
|
492
555
|
_setDisplay(display, options = {}) {
|
|
493
556
|
const previous = this._model.display;
|
|
494
|
-
this._model.setDisplay(display);
|
|
557
|
+
this._model.setDisplay(clampDisplayMonth(display, this.firstDay));
|
|
495
558
|
this._model.focused = this._clamp(this._model.focused);
|
|
496
559
|
this._model.display = monthKey(this._model.focused);
|
|
497
560
|
this._reflect("display", this._model.display);
|
|
@@ -512,12 +575,12 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
512
575
|
|
|
513
576
|
/** @public */
|
|
514
577
|
previousMonth() {
|
|
515
|
-
return this._setDisplay(
|
|
578
|
+
return this._setDisplay(stepMonthKey(this.display, -1) ?? this.display);
|
|
516
579
|
}
|
|
517
580
|
|
|
518
581
|
/** @public */
|
|
519
582
|
nextMonth() {
|
|
520
|
-
return this._setDisplay(
|
|
583
|
+
return this._setDisplay(stepMonthKey(this.display, 1) ?? this.display);
|
|
521
584
|
}
|
|
522
585
|
|
|
523
586
|
/** @public @param {string} date @param {{moveFocus?:boolean}} [options] */
|
|
@@ -572,14 +635,72 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
572
635
|
return false;
|
|
573
636
|
}
|
|
574
637
|
|
|
638
|
+
/**
|
|
639
|
+
* Accessible name of one day cell. The band contribution is dropped while a
|
|
640
|
+
* preview is projected onto `highlightedRange`: a proposal must not be
|
|
641
|
+
* announced as a committed range start/end.
|
|
642
|
+
* @param {string} date @param {DateState} state
|
|
643
|
+
* @param {"" | "start" | "in" | "end" | "single"} position
|
|
644
|
+
*/
|
|
645
|
+
_dayLabel(date, state, position) {
|
|
646
|
+
const rangeLabel = this.hasAttribute("data-range-preview") ? "" : rangeLabelKey(position);
|
|
647
|
+
const description = typeof state.description === "string" ? state.description : "";
|
|
648
|
+
return [
|
|
649
|
+
formatLongDate(date, this.locale),
|
|
650
|
+
description,
|
|
651
|
+
rangeLabel ? this._messages[rangeLabel] : "",
|
|
652
|
+
state.disabled ? this._messages.unavailable : "",
|
|
653
|
+
]
|
|
654
|
+
.filter(Boolean)
|
|
655
|
+
.join(". ");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Repaint the range band over the cells already rendered. The band is
|
|
660
|
+
* presentation on top of the same grid, so it must never rebuild it: a full
|
|
661
|
+
* render would drop `renderDay()` nodes and fight the roving focus on every
|
|
662
|
+
* hover of a live range preview.
|
|
663
|
+
*/
|
|
664
|
+
_updateRangeDom() {
|
|
665
|
+
const previousRange = this._paintedRange;
|
|
666
|
+
const previousPreview = this._paintedPreview;
|
|
667
|
+
this._paintedRange = { ...this._highlightedRange };
|
|
668
|
+
this._paintedPreview = this.hasAttribute("data-range-preview");
|
|
669
|
+
for (const cell of this.querySelectorAll(".dp-day[data-date]")) {
|
|
670
|
+
if (!(cell instanceof HTMLTableCellElement)) continue;
|
|
671
|
+
const date = cell.dataset.date || "";
|
|
672
|
+
const position = rangePosition(date, this._highlightedRange);
|
|
673
|
+
if (position === rangePosition(date, previousRange) && previousPreview === this._paintedPreview)
|
|
674
|
+
continue;
|
|
675
|
+
for (const [name, on] of Object.entries(rangeAttributeMap(position))) {
|
|
676
|
+
if (on) cell.setAttribute(name, "true");
|
|
677
|
+
else cell.removeAttribute(name);
|
|
678
|
+
}
|
|
679
|
+
// A moving preview changes only the band. Its accessible labels always
|
|
680
|
+
// omit range roles; entering/leaving preview still updates those labels.
|
|
681
|
+
if (!previousPreview || !this._paintedPreview) {
|
|
682
|
+
const state = this._renderedStates.get(date) || {};
|
|
683
|
+
const label = this._dayLabel(date, state, position);
|
|
684
|
+
if (cell.getAttribute("aria-label") !== label) cell.setAttribute("aria-label", label);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
575
689
|
/** @public */
|
|
576
690
|
focusGrid() {
|
|
577
691
|
const target = this.querySelector(`.dp-day[data-date="${CSS.escape(this.focusedDate)}"]`);
|
|
578
692
|
if (target instanceof HTMLElement) target.focus();
|
|
579
693
|
}
|
|
580
694
|
|
|
581
|
-
/**
|
|
582
|
-
|
|
695
|
+
/**
|
|
696
|
+
* Run one explicit activation: availability check, then the cancelable
|
|
697
|
+
* `dateactivate` seam, then selection. Click, keyboard and any other input
|
|
698
|
+
* device share this single path, so an application that cancels
|
|
699
|
+
* `dateactivate` cannot be bypassed by a new interaction.
|
|
700
|
+
* @public
|
|
701
|
+
* @param {string} date @returns {Promise<boolean>}
|
|
702
|
+
*/
|
|
703
|
+
async activateDate(date) {
|
|
583
704
|
// Guarantee remote state before deciding: a date clicked before the
|
|
584
705
|
// source resolves must not be treated as available, and a cancelled or
|
|
585
706
|
// failed load must not authorize it either.
|
|
@@ -633,7 +754,7 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
633
754
|
this._model.setFocused(this._clamp(date));
|
|
634
755
|
this._reflect("display", this._model.display);
|
|
635
756
|
if (previousDisplay !== this.display) this._emitDisplayChange();
|
|
636
|
-
void this.
|
|
757
|
+
void this.activateDate(date);
|
|
637
758
|
}
|
|
638
759
|
|
|
639
760
|
/** @param {Event} event */
|
|
@@ -645,7 +766,9 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
645
766
|
}
|
|
646
767
|
if (target instanceof HTMLInputElement && target.matches(".dp-year-input")) {
|
|
647
768
|
const year = Number(target.value);
|
|
648
|
-
|
|
769
|
+
// 9999 is a valid civil year but its December grid overflows into 10000,
|
|
770
|
+
// so the editable range stops one year short of the unrenderable month.
|
|
771
|
+
if (!Number.isInteger(year) || year < 1 || year > 9998) {
|
|
649
772
|
if (this._connected) this.render(false);
|
|
650
773
|
return;
|
|
651
774
|
}
|
|
@@ -653,12 +776,22 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
653
776
|
}
|
|
654
777
|
}
|
|
655
778
|
|
|
656
|
-
/**
|
|
779
|
+
/**
|
|
780
|
+
* Real DOM focus is the single source of truth for `datefocus`: the model is
|
|
781
|
+
* synced here and the event is announced from here, so the temporary focus
|
|
782
|
+
* arithmetic in `_onKeyDown` cannot emit phantom events, and setting
|
|
783
|
+
* `focusedDate` without moving focus stays silent.
|
|
784
|
+
* @param {FocusEvent} event
|
|
785
|
+
*/
|
|
657
786
|
_onFocusIn(event) {
|
|
658
787
|
const target = event.target;
|
|
659
788
|
if (!(target instanceof HTMLTableCellElement) || !target.matches(".dp-day[data-date]")) return;
|
|
660
789
|
const date = target.dataset.date || "";
|
|
661
|
-
if (isDate(date))
|
|
790
|
+
if (!isDate(date)) return;
|
|
791
|
+
this._model.focused = date;
|
|
792
|
+
this.dispatchEvent(
|
|
793
|
+
new CustomEvent("datefocus", { detail: { date, state: this.getDateState(date) }, bubbles: true }),
|
|
794
|
+
);
|
|
662
795
|
}
|
|
663
796
|
|
|
664
797
|
/** @param {KeyboardEvent} event */
|
|
@@ -668,7 +801,7 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
668
801
|
const date = target.dataset.date || this.focusedDate;
|
|
669
802
|
if (event.key === "Enter" || event.key === " ") {
|
|
670
803
|
event.preventDefault();
|
|
671
|
-
void this.
|
|
804
|
+
void this.activateDate(date);
|
|
672
805
|
return;
|
|
673
806
|
}
|
|
674
807
|
// Movement primitives live in CalendarModel; the DOM only syncs render/focus.
|
|
@@ -712,8 +845,10 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
712
845
|
const gridId = `${this._id}-grid`;
|
|
713
846
|
const minMonth = this.min ? monthKey(this.min) : "";
|
|
714
847
|
const maxMonth = this.max ? monthKey(this.max) : "";
|
|
715
|
-
const
|
|
716
|
-
const
|
|
848
|
+
const prevKey = stepMonthKey(display, -1);
|
|
849
|
+
const nextKey = stepMonthKey(display, 1);
|
|
850
|
+
const prevDisabled = Boolean(minMonth && prevKey !== null && prevKey < minMonth);
|
|
851
|
+
const nextDisabled = Boolean(maxMonth && nextKey !== null && nextKey > maxMonth);
|
|
717
852
|
const minYearAttr = this.min ? ` min="${Number(this.min.slice(0, 4))}"` : "";
|
|
718
853
|
const maxYearAttr = this.max ? ` max="${Number(this.max.slice(0, 4))}"` : "";
|
|
719
854
|
const showWeeks = this.showWeekNumbers && this.firstDay === 1;
|
|
@@ -737,6 +872,7 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
737
872
|
)
|
|
738
873
|
.join("");
|
|
739
874
|
|
|
875
|
+
this._renderedStates.clear();
|
|
740
876
|
const rows = weeks
|
|
741
877
|
.map((week) => {
|
|
742
878
|
const weekNumber = showWeeks
|
|
@@ -745,21 +881,13 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
745
881
|
const cells = week
|
|
746
882
|
.map((date) => {
|
|
747
883
|
const state = this.getDateState(date);
|
|
884
|
+
this._renderedStates.set(date, state);
|
|
748
885
|
const outside = monthKey(date) !== display;
|
|
749
886
|
const selected = this.value === date;
|
|
750
887
|
const focused = this.focusedDate === date;
|
|
751
888
|
const isToday = date === today;
|
|
752
889
|
const position = rangePosition(date, this._highlightedRange);
|
|
753
|
-
const
|
|
754
|
-
const description = typeof state.description === "string" ? state.description : "";
|
|
755
|
-
const label = [
|
|
756
|
-
formatLongDate(date, locale),
|
|
757
|
-
description,
|
|
758
|
-
rangeLabel ? this._messages[rangeLabel] : "",
|
|
759
|
-
state.disabled ? this._messages.unavailable : "",
|
|
760
|
-
]
|
|
761
|
-
.filter(Boolean)
|
|
762
|
-
.join(". ");
|
|
890
|
+
const label = this._dayLabel(date, state, position);
|
|
763
891
|
return `<td class="dp-day" data-date="${date}"${outside ? ' data-outside-month="true"' : ""}${isToday ? ' data-today="true" aria-current="date"' : ""}${selected ? ' data-selected="true" aria-selected="true"' : ""}${rangeAttributes(position)}${state.disabled ? ' data-disabled="true" aria-disabled="true"' : ""} tabindex="${focused ? "0" : "-1"}" aria-label="${escapeHtml(label)}"><span class="dp-day-number" aria-hidden="true">${Number(date.slice(8, 10))}</span><span class="dp-day-extra" aria-hidden="true"></span></td>`;
|
|
764
892
|
})
|
|
765
893
|
.join("");
|
|
@@ -778,10 +906,14 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
778
906
|
// select via `data-year-first`.
|
|
779
907
|
const yearFirst = monthYearOrder(locale)[0] === "year";
|
|
780
908
|
this.toggleAttribute("data-year-first", yearFirst);
|
|
909
|
+
const classes = this._controlClasses;
|
|
910
|
+
const monthClass = escapeHtml(classes.month || "dp-skin");
|
|
911
|
+
const yearClass = escapeHtml(classes.year || "dp-skin");
|
|
912
|
+
const navClass = escapeHtml(classes.nav || "dp-skin");
|
|
781
913
|
const monthControl = `<label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
|
|
782
|
-
<select id="${this._id}-month" class="dp-month-select" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>`;
|
|
914
|
+
<select id="${this._id}-month" class="dp-month-select ${monthClass}" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>`;
|
|
783
915
|
const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
|
|
784
|
-
<input id="${this._id}-year" class="dp-year-input" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">`;
|
|
916
|
+
<input id="${this._id}-year" class="dp-year-input ${yearClass}" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">`;
|
|
785
917
|
|
|
786
918
|
this._rendering = true;
|
|
787
919
|
this.innerHTML = `
|
|
@@ -789,8 +921,8 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
789
921
|
<div class="dp-calendar-header">
|
|
790
922
|
${yearFirst ? yearControl : monthControl}
|
|
791
923
|
${yearFirst ? monthControl : yearControl}
|
|
792
|
-
<button type="button" class="dp-nav dp-prev" data-calendar-action="previous" aria-label="${escapeHtml(this._messages.previousMonth)}"${prevDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="
|
|
793
|
-
<button type="button" class="dp-nav dp-next" data-calendar-action="next" aria-label="${escapeHtml(this._messages.nextMonth)}"${nextDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="
|
|
924
|
+
<button type="button" class="dp-nav dp-prev ${navClass}" data-calendar-action="previous" aria-label="${escapeHtml(this._messages.previousMonth)}"${prevDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4 10 4-4 4 4"/></svg></button>
|
|
925
|
+
<button type="button" class="dp-nav dp-next ${navClass}" data-calendar-action="next" aria-label="${escapeHtml(this._messages.nextMonth)}"${nextDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4 6 4 4 4-4"/></svg></button>
|
|
794
926
|
</div>
|
|
795
927
|
<h2 id="${headingId}" class="dp-calendar-heading dp-visually-hidden" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
|
|
796
928
|
<table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
|
|
@@ -799,6 +931,8 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
799
931
|
</table>
|
|
800
932
|
</div>`;
|
|
801
933
|
this._rendering = false;
|
|
934
|
+
this._paintedRange = { ...this._highlightedRange };
|
|
935
|
+
this._paintedPreview = this.hasAttribute("data-range-preview");
|
|
802
936
|
|
|
803
937
|
if (focusKey) {
|
|
804
938
|
queueMicrotask(() => {
|
|
@@ -822,7 +956,7 @@ export class DateCalendarElement extends HTMLElement {
|
|
|
822
956
|
const date = cell.dataset.date || "";
|
|
823
957
|
const extra = cell.querySelector(".dp-day-extra");
|
|
824
958
|
if (!(extra instanceof HTMLElement)) continue;
|
|
825
|
-
const content = this._renderDay(date, this.
|
|
959
|
+
const content = this._renderDay(date, this._renderedStates.get(date) || {});
|
|
826
960
|
if (content instanceof Node) extra.append(content);
|
|
827
961
|
else if (content !== undefined && content !== null) extra.textContent = String(content);
|
|
828
962
|
}
|
package/src/date-field.js
CHANGED
|
@@ -33,8 +33,9 @@ export class DateFieldController {
|
|
|
33
33
|
this.onAttributesChanged = null;
|
|
34
34
|
/**
|
|
35
35
|
* Async availability gate for a parsed date. Returning `{ ok: false }`
|
|
36
|
-
* marks the target unavailable. Swappable at runtime.
|
|
37
|
-
*
|
|
36
|
+
* marks the target unavailable. Swappable at runtime. The commit options
|
|
37
|
+
* are forwarded so the gate can honor `moveDisplay`.
|
|
38
|
+
* @type {((date: string, options?: {moveDisplay?: boolean}) => Promise<{ok: boolean, message?: string}>) | null}
|
|
38
39
|
*/
|
|
39
40
|
this.confirm = null;
|
|
40
41
|
}
|
|
@@ -137,16 +138,19 @@ export class DateFieldController {
|
|
|
137
138
|
input.setCustomValidity("");
|
|
138
139
|
return;
|
|
139
140
|
}
|
|
140
|
-
input.setCustomValidity(
|
|
141
|
+
input.setCustomValidity(this.messages.invalidDate);
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
/**
|
|
144
145
|
* Commit the visible text. Returns the parsed canonical date on success, the
|
|
145
146
|
* empty string on a clear, or a falsy marker for invalid/stale commits. The
|
|
146
147
|
* caller applies the canonical value so reflection and events stay central.
|
|
148
|
+
* `options` is forwarded to the `confirm` gate so a revalidation can ask it
|
|
149
|
+
* not to move the calendar display (source reloads must not re-home it).
|
|
150
|
+
* @param {{moveDisplay?: boolean}} [options]
|
|
147
151
|
* @returns {Promise<{status: "ok" | "clear" | "invalid" | "stale", value?: string}>}
|
|
148
152
|
*/
|
|
149
|
-
async commit() {
|
|
153
|
+
async commit(options = {}) {
|
|
150
154
|
const input = this.input;
|
|
151
155
|
const commitId = ++this._commitId;
|
|
152
156
|
const text = input.value.trim();
|
|
@@ -163,7 +167,7 @@ export class DateFieldController {
|
|
|
163
167
|
return { status: "invalid" };
|
|
164
168
|
}
|
|
165
169
|
if (this.confirm) {
|
|
166
|
-
const result = await this.confirm(parsed);
|
|
170
|
+
const result = await this.confirm(parsed, options);
|
|
167
171
|
if (commitId !== this._commitId) return { status: "stale" };
|
|
168
172
|
if (!result?.ok) {
|
|
169
173
|
if (this.hidden) this.hidden.value = "";
|
package/src/date-picker.css
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
--dp-hover: color-mix(in srgb, var(--dp-fg) 5%, var(--dp-bg));
|
|
7
7
|
--dp-accent: #2563eb;
|
|
8
8
|
--dp-accent-fg: #fff;
|
|
9
|
+
--dp-focus: var(--dp-accent);
|
|
10
|
+
--dp-focus-width: 2px;
|
|
9
11
|
--dp-radius: 0.5rem;
|
|
10
12
|
--dp-day-size: 2.75rem;
|
|
11
13
|
--dp-gap: 0.375rem;
|
|
@@ -44,7 +46,7 @@ date-calendar {
|
|
|
44
46
|
|
|
45
47
|
.dp-calendar-header {
|
|
46
48
|
display: grid;
|
|
47
|
-
grid-template-columns: minmax(0, 1fr)
|
|
49
|
+
grid-template-columns: minmax(0, 1fr) var(--dp-year-inline-size) auto auto;
|
|
48
50
|
/* Stretch (not center): once a UA natural height exceeds
|
|
49
51
|
--dp-header-control-size, the row grows to the tallest control and the
|
|
50
52
|
others stretch to match, so month/year/nav stay equal. */
|
|
@@ -60,12 +62,16 @@ date-calendar {
|
|
|
60
62
|
track stays under the month select so the fixed-width year input never
|
|
61
63
|
stretches. Visual mirroring itself is left to the inherited `dir`. */
|
|
62
64
|
date-calendar[data-year-first] .dp-calendar-header {
|
|
63
|
-
grid-template-columns:
|
|
65
|
+
grid-template-columns: var(--dp-year-inline-size) minmax(0, 1fr) auto auto;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
.dp-calendar-header :where(button, select, input) {
|
|
67
|
-
min-block-size: var(--dp-header-control-size);
|
|
68
69
|
box-sizing: border-box;
|
|
70
|
+
min-inline-size: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.dp-calendar-header .dp-skin {
|
|
74
|
+
min-block-size: var(--dp-header-control-size);
|
|
69
75
|
/* Shared vertical metrics: UA default padding/line-height differ per control
|
|
70
76
|
(up to ~13px apart), which only shows once --dp-header-control-size drops
|
|
71
77
|
below content size. */
|
|
@@ -78,8 +84,7 @@ date-calendar[data-year-first] .dp-calendar-header {
|
|
|
78
84
|
line-height: 1.2;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
.dp-calendar-header input {
|
|
82
|
-
min-inline-size: 0;
|
|
87
|
+
.dp-calendar-header input.dp-skin {
|
|
83
88
|
padding-inline: 0.45rem;
|
|
84
89
|
}
|
|
85
90
|
|
|
@@ -89,11 +94,13 @@ date-calendar[data-year-first] .dp-calendar-header {
|
|
|
89
94
|
control and the others stretch to match. */
|
|
90
95
|
.dp-month-select {
|
|
91
96
|
inline-size: 100%;
|
|
92
|
-
min-inline-size: 0;
|
|
93
97
|
/* Long month names in a narrow track (compact consumers, long locales)
|
|
94
98
|
would paint under the caret: truncate with an ellipsis instead. */
|
|
95
99
|
overflow: hidden;
|
|
96
100
|
text-overflow: ellipsis;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.dp-month-select.dp-skin {
|
|
97
104
|
appearance: none;
|
|
98
105
|
padding-inline: 0.45rem 2rem;
|
|
99
106
|
background-image:
|
|
@@ -109,18 +116,18 @@ date-calendar[data-year-first] .dp-calendar-header {
|
|
|
109
116
|
/* Inherited RTL mirrors the header layout via the grid. The logical
|
|
110
117
|
`padding-inline` already flips, so only the physically-positioned caret has
|
|
111
118
|
to follow it to the other side. */
|
|
112
|
-
[dir="rtl"] .dp-month-select,
|
|
113
|
-
html[dir="rtl"] .dp-month-select {
|
|
119
|
+
[dir="rtl"] .dp-month-select.dp-skin,
|
|
120
|
+
html[dir="rtl"] .dp-month-select.dp-skin {
|
|
114
121
|
background-position:
|
|
115
122
|
0.9rem 50%,
|
|
116
123
|
0.65rem 50%;
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
.dp-year-input {
|
|
120
|
-
inline-size:
|
|
127
|
+
inline-size: 100%;
|
|
121
128
|
}
|
|
122
129
|
|
|
123
|
-
.dp-nav {
|
|
130
|
+
.dp-nav.dp-skin {
|
|
124
131
|
display: grid;
|
|
125
132
|
place-items: center;
|
|
126
133
|
inline-size: var(--dp-header-control-size);
|
|
@@ -137,30 +144,23 @@ html[dir="rtl"] .dp-month-select {
|
|
|
137
144
|
pointer-events: none;
|
|
138
145
|
}
|
|
139
146
|
|
|
140
|
-
|
|
141
|
-
so previous/next keep their visual sense. Actions stay chronological: no
|
|
142
|
-
button swap, no keyboard remapping. */
|
|
143
|
-
[dir="rtl"] .dp-nav svg,
|
|
144
|
-
html[dir="rtl"] .dp-nav svg {
|
|
145
|
-
transform: scaleX(-1);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
.dp-calendar-header :where(button, select, input):disabled,
|
|
147
|
+
.dp-calendar-header .dp-skin:disabled,
|
|
149
148
|
.dp-picker-button:disabled {
|
|
150
149
|
cursor: not-allowed;
|
|
151
150
|
opacity: 0.45;
|
|
152
151
|
}
|
|
153
152
|
|
|
154
|
-
.dp-nav:hover:not(:disabled) {
|
|
153
|
+
.dp-nav.dp-skin:hover:not(:disabled) {
|
|
155
154
|
background: var(--dp-hover);
|
|
156
155
|
}
|
|
157
156
|
|
|
158
|
-
/*
|
|
159
|
-
|
|
160
|
-
.dp-calendar-header :
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
157
|
+
/* Inner outlines survive forced colors and keep focus inside each control.
|
|
158
|
+
The picker trigger uses a tighter inset around its icon below. */
|
|
159
|
+
.dp-calendar-header .dp-skin:focus-visible,
|
|
160
|
+
date-picker > input:not([type="hidden"]):focus-visible,
|
|
161
|
+
.dp-day:focus-visible {
|
|
162
|
+
outline: var(--dp-focus-width) solid var(--dp-focus);
|
|
163
|
+
outline-offset: calc(var(--dp-focus-width) * -1);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
.dp-day:hover:not([data-disabled="true"]):not([data-selected="true"]):not([data-range-start="true"]):not(
|
|
@@ -205,15 +205,6 @@ html[dir="rtl"] .dp-nav svg {
|
|
|
205
205
|
font-size: 0.875rem;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
/* Inner ring: keyboard focus stays inside the cell instead of reading as a
|
|
209
|
-
second selected state or bleeding onto a neighbouring range band. It is an
|
|
210
|
-
`outline` (not an inset box-shadow) because forced colors computes
|
|
211
|
-
box-shadow to `none`, which would erase the keyboard indicator entirely. */
|
|
212
|
-
.dp-day:focus-visible {
|
|
213
|
-
outline: 2px solid var(--dp-accent);
|
|
214
|
-
outline-offset: -2px;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
208
|
/* On an accent-filled cell the ring inverts so it stays readable. */
|
|
218
209
|
.dp-day:is([data-selected="true"], [data-range-start="true"], [data-range-end="true"]):focus-visible {
|
|
219
210
|
outline-color: var(--dp-accent-fg);
|
|
@@ -275,6 +266,28 @@ html[dir="rtl"] .dp-nav svg {
|
|
|
275
266
|
border-end-end-radius: var(--dp-day-radius);
|
|
276
267
|
}
|
|
277
268
|
|
|
269
|
+
/* Projected band: the range the next commit would produce. It reads as one
|
|
270
|
+
step short of the committed band, so a hovered, keyboard or dragged preview
|
|
271
|
+
is never mistaken for the selection itself. */
|
|
272
|
+
[data-range-preview] .dp-day:is([data-range-start="true"], [data-range-end="true"]) {
|
|
273
|
+
background: color-mix(in srgb, var(--dp-accent) 58%, var(--dp-bg));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
[data-range-preview] .dp-day[data-in-range="true"] {
|
|
277
|
+
background: color-mix(in srgb, var(--dp-accent) 11%, var(--dp-bg));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Endpoint handles are draggable only while the committed range is complete
|
|
281
|
+
and its two ends sit on different days. */
|
|
282
|
+
[data-range-drag] .dp-day:is([data-range-start="true"], [data-range-end="true"]) {
|
|
283
|
+
cursor: grab;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
[data-range-drag="active"],
|
|
287
|
+
[data-range-drag="active"] .dp-day {
|
|
288
|
+
cursor: grabbing;
|
|
289
|
+
}
|
|
290
|
+
|
|
278
291
|
.dp-day-number {
|
|
279
292
|
display: block;
|
|
280
293
|
font-variant-numeric: tabular-nums;
|
|
@@ -320,14 +333,6 @@ date-picker {
|
|
|
320
333
|
max-inline-size: 100%;
|
|
321
334
|
}
|
|
322
335
|
|
|
323
|
-
/* Visible focus is normalized to --dp-accent so the component holds together
|
|
324
|
-
without a consumer stylesheet; the field's own look stays consumer-owned. */
|
|
325
|
-
date-picker > input:not([type="hidden"]):focus-visible {
|
|
326
|
-
border-color: var(--dp-accent);
|
|
327
|
-
outline: none;
|
|
328
|
-
box-shadow: 0 0 0 3px color-mix(in srgb, var(--dp-accent) 20%, transparent);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
336
|
/* Every authored control is a grid item. */
|
|
332
337
|
date-picker > input:not([type="hidden"]) {
|
|
333
338
|
min-inline-size: 0;
|
|
@@ -368,7 +373,7 @@ date-picker > input:not([type="hidden"]):not([type="time"]) {
|
|
|
368
373
|
field already shows its own ring through :focus-visible). Same radius as
|
|
369
374
|
everywhere else: no bespoke shape. */
|
|
370
375
|
.dp-picker-button:focus-visible {
|
|
371
|
-
outline:
|
|
376
|
+
outline: var(--dp-focus-width) solid var(--dp-focus);
|
|
372
377
|
outline-offset: -5px;
|
|
373
378
|
border-radius: var(--dp-radius);
|
|
374
379
|
}
|
|
@@ -446,8 +451,8 @@ date-picker[range] > .dp-picker-button[data-endpoint="end"] {
|
|
|
446
451
|
@media (forced-colors: active) {
|
|
447
452
|
/* The author-drawn caret is a gradient and disappears here: hand the
|
|
448
453
|
control back to the browser instead of trying to rebuild it. */
|
|
449
|
-
.dp-month-select,
|
|
450
|
-
[dir="rtl"] .dp-month-select {
|
|
454
|
+
.dp-month-select.dp-skin,
|
|
455
|
+
[dir="rtl"] .dp-month-select.dp-skin {
|
|
451
456
|
appearance: auto;
|
|
452
457
|
background-image: none;
|
|
453
458
|
padding-inline: 0.45rem;
|