@lekoala/date-picker 0.1.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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -0
  3. package/custom-elements.json +366 -0
  4. package/dist/date-picker.css +344 -0
  5. package/dist/date-picker.js +2693 -0
  6. package/dist/date-picker.min.css +1 -0
  7. package/dist/date-picker.min.js +17 -0
  8. package/dist/date-picker.standalone.min.js +361 -0
  9. package/dist/types/calendar-model.d.ts +34 -0
  10. package/dist/types/calendar-model.d.ts.map +1 -0
  11. package/dist/types/date-calendar.d.ts +228 -0
  12. package/dist/types/date-calendar.d.ts.map +1 -0
  13. package/dist/types/date-field.d.ts +94 -0
  14. package/dist/types/date-field.d.ts.map +1 -0
  15. package/dist/types/date-picker.d.ts +258 -0
  16. package/dist/types/date-picker.d.ts.map +1 -0
  17. package/dist/types/date-range.d.ts +89 -0
  18. package/dist/types/date-range.d.ts.map +1 -0
  19. package/dist/types/date.d.ts +61 -0
  20. package/dist/types/date.d.ts.map +1 -0
  21. package/dist/types/define.d.ts +2 -0
  22. package/dist/types/define.d.ts.map +1 -0
  23. package/dist/types/index.d.ts +9 -0
  24. package/dist/types/index.d.ts.map +1 -0
  25. package/dist/types/intl.d.ts +33 -0
  26. package/dist/types/intl.d.ts.map +1 -0
  27. package/dist/types/locales/ar.d.ts +22 -0
  28. package/dist/types/locales/ar.d.ts.map +1 -0
  29. package/dist/types/locales/de.d.ts +22 -0
  30. package/dist/types/locales/de.d.ts.map +1 -0
  31. package/dist/types/locales/en.d.ts +22 -0
  32. package/dist/types/locales/en.d.ts.map +1 -0
  33. package/dist/types/locales/es.d.ts +22 -0
  34. package/dist/types/locales/es.d.ts.map +1 -0
  35. package/dist/types/locales/fr.d.ts +22 -0
  36. package/dist/types/locales/fr.d.ts.map +1 -0
  37. package/dist/types/locales/hi.d.ts +22 -0
  38. package/dist/types/locales/hi.d.ts.map +1 -0
  39. package/dist/types/locales/id.d.ts +22 -0
  40. package/dist/types/locales/id.d.ts.map +1 -0
  41. package/dist/types/locales/it.d.ts +22 -0
  42. package/dist/types/locales/it.d.ts.map +1 -0
  43. package/dist/types/locales/ja.d.ts +22 -0
  44. package/dist/types/locales/ja.d.ts.map +1 -0
  45. package/dist/types/locales/ko.d.ts +22 -0
  46. package/dist/types/locales/ko.d.ts.map +1 -0
  47. package/dist/types/locales/nl.d.ts +22 -0
  48. package/dist/types/locales/nl.d.ts.map +1 -0
  49. package/dist/types/locales/pl.d.ts +22 -0
  50. package/dist/types/locales/pl.d.ts.map +1 -0
  51. package/dist/types/locales/pt-BR.d.ts +22 -0
  52. package/dist/types/locales/pt-BR.d.ts.map +1 -0
  53. package/dist/types/locales/pt-PT.d.ts +22 -0
  54. package/dist/types/locales/pt-PT.d.ts.map +1 -0
  55. package/dist/types/locales/ru.d.ts +22 -0
  56. package/dist/types/locales/ru.d.ts.map +1 -0
  57. package/dist/types/locales/tr.d.ts +22 -0
  58. package/dist/types/locales/tr.d.ts.map +1 -0
  59. package/dist/types/locales/zh-CN.d.ts +22 -0
  60. package/dist/types/locales/zh-CN.d.ts.map +1 -0
  61. package/dist/types/messages.d.ts +43 -0
  62. package/dist/types/messages.d.ts.map +1 -0
  63. package/dist/types/source.d.ts +28 -0
  64. package/dist/types/source.d.ts.map +1 -0
  65. package/docs/ACCESSIBILITY.md +128 -0
  66. package/docs/API.md +289 -0
  67. package/docs/ARCHITECTURE.md +183 -0
  68. package/docs/AT-CHECKLIST.md +63 -0
  69. package/docs/CALENDAR-INTEGRATION.md +70 -0
  70. package/docs/DECISIONS.md +40 -0
  71. package/docs/MIGRATION-FLATPICKR.md +92 -0
  72. package/docs/ROADMAP.md +105 -0
  73. package/docs/TESTING.md +73 -0
  74. package/docs/USE_CASES.md +128 -0
  75. package/package.json +174 -0
  76. package/src/calendar-model.js +80 -0
  77. package/src/date-calendar.js +800 -0
  78. package/src/date-field.js +203 -0
  79. package/src/date-picker.css +344 -0
  80. package/src/date-picker.js +1140 -0
  81. package/src/date-range.js +172 -0
  82. package/src/date.js +203 -0
  83. package/src/define.js +9 -0
  84. package/src/index.js +15 -0
  85. package/src/intl.js +155 -0
  86. package/src/locales/ar.js +20 -0
  87. package/src/locales/de.js +20 -0
  88. package/src/locales/en.js +20 -0
  89. package/src/locales/es.js +20 -0
  90. package/src/locales/fr.js +20 -0
  91. package/src/locales/hi.js +20 -0
  92. package/src/locales/id.js +20 -0
  93. package/src/locales/it.js +20 -0
  94. package/src/locales/ja.js +20 -0
  95. package/src/locales/ko.js +20 -0
  96. package/src/locales/nl.js +20 -0
  97. package/src/locales/pl.js +20 -0
  98. package/src/locales/pt-BR.js +20 -0
  99. package/src/locales/pt-PT.js +20 -0
  100. package/src/locales/ru.js +20 -0
  101. package/src/locales/tr.js +20 -0
  102. package/src/locales/zh-CN.js +20 -0
  103. package/src/messages.js +31 -0
  104. package/src/source.js +50 -0
@@ -0,0 +1,800 @@
1
+ import { CalendarModel } from "./calendar-model.js";
2
+ import {
3
+ addDays,
4
+ clampDate,
5
+ compareDates,
6
+ getMonthWeeks,
7
+ isDate,
8
+ isoWeekNumber,
9
+ monthKey,
10
+ shiftMonth,
11
+ todayISO,
12
+ } from "./date.js";
13
+ import { normalizeRange, rangePosition } from "./date-range.js";
14
+ import { formatLongDate, formatMonthYear, monthNames, resolveLocale, weekdayNames } from "./intl.js";
15
+ import { getDefaultMessages } from "./messages.js";
16
+ import { normalizeDateStates } from "./source.js";
17
+
18
+ let uid = 0;
19
+
20
+ /** @param {unknown} value */
21
+ function escapeHtml(value) {
22
+ return String(value)
23
+ .replaceAll("&", "&")
24
+ .replaceAll("<", "&lt;")
25
+ .replaceAll(">", "&gt;")
26
+ .replaceAll('"', "&quot;")
27
+ .replaceAll("'", "&#039;");
28
+ }
29
+
30
+ /** @param {string} value */
31
+ function parseFirstDay(value) {
32
+ const parsed = Number(value);
33
+ return Number.isInteger(parsed) && parsed >= 0 && parsed <= 6 ? parsed : 1;
34
+ }
35
+
36
+ /** @param {string} month */
37
+ function yearOf(month) {
38
+ return Number(month.slice(0, 4));
39
+ }
40
+
41
+ /** @param {"" | "start" | "in" | "end" | "single"} position */
42
+ function rangeAttributes(position) {
43
+ switch (position) {
44
+ case "single":
45
+ return ' data-range-start="true" data-range-end="true"';
46
+ case "start":
47
+ return ' data-range-start="true"';
48
+ case "end":
49
+ return ' data-range-end="true"';
50
+ case "in":
51
+ return ' data-in-range="true"';
52
+ default:
53
+ return "";
54
+ }
55
+ }
56
+
57
+ /** @param {"" | "start" | "in" | "end" | "single"} position */
58
+ function rangeLabelKey(position) {
59
+ if (position === "single") return "rangeSingle";
60
+ if (position === "start") return "rangeStart";
61
+ if (position === "end") return "rangeEnd";
62
+ if (position === "in") return "rangeInRange";
63
+ return "";
64
+ }
65
+
66
+ /**
67
+ * Public inline calendar primitive.
68
+ *
69
+ * State contract:
70
+ * - display: month being rendered (YYYY-MM)
71
+ * - focusedDate: roving keyboard target (YYYY-MM-DD)
72
+ * - value: selected date (YYYY-MM-DD or empty)
73
+ * - highlightedRange: presentation-only range band (never touches the other state)
74
+ * - dateactivate: user explicitly activated one date
75
+ */
76
+ /** @typedef {{start:string,end:string}} DateRange */
77
+ /** @typedef {Record<string, unknown> & {disabled?:boolean, description?:string}} DateState */
78
+ /** @typedef {(range:DateRange, context:{signal:AbortSignal}) => unknown | Promise<unknown>} DateLoader */
79
+ /** @typedef {DateLoader | {load:DateLoader}} DateSource */
80
+ /** @typedef {(date:string, sourceState:DateState) => DateState | null | undefined} DateStateResolver */
81
+ /** @typedef {(date:string, state:DateState) => Node | string | null | undefined} DayRenderer */
82
+ /** @typedef {(date:string) => boolean} DateDisabledPredicate */
83
+
84
+ export class DateCalendarElement extends HTMLElement {
85
+ static observedAttributes = [
86
+ "value",
87
+ "display",
88
+ "min",
89
+ "max",
90
+ "first-day",
91
+ "locale",
92
+ "fixed-weeks",
93
+ "show-week-numbers",
94
+ "selection",
95
+ ];
96
+
97
+ constructor() {
98
+ super();
99
+ this._id = `date-calendar-${++uid}`;
100
+ this._model = new CalendarModel();
101
+ this._connected = false;
102
+ this._rendering = false;
103
+ /** @type {DateSource | null} */
104
+ this._source = null;
105
+ this._sourceStates = new Map();
106
+ this._loadedRanges = new Set();
107
+ this._loadController = null;
108
+ this._loadingKey = "";
109
+ /** @type {Promise<"loaded" | "cancelled" | "failed"> | null} */
110
+ this._loadingPromise = null;
111
+ /** @type {DateStateResolver | null} */
112
+ this._dateState = null;
113
+ /** @type {DayRenderer | null} */
114
+ this._renderDay = null;
115
+ /** @type {DateDisabledPredicate | null} */
116
+ this._isDateDisabled = null;
117
+ /** @type {DateRange} */
118
+ this._highlightedRange = { start: "", end: "" };
119
+ this._messages = getDefaultMessages();
120
+ this._onClick = this._onClick.bind(this);
121
+ this._onChange = this._onChange.bind(this);
122
+ this._onKeyDown = this._onKeyDown.bind(this);
123
+ this._onFocusIn = this._onFocusIn.bind(this);
124
+ }
125
+
126
+ connectedCallback() {
127
+ if (this._connected) return;
128
+ this._connected = true;
129
+ this.addEventListener("click", this._onClick);
130
+ this.addEventListener("change", this._onChange);
131
+ this.addEventListener("keydown", this._onKeyDown);
132
+ this.addEventListener("focusin", this._onFocusIn);
133
+ this._syncModel();
134
+ this.render();
135
+ }
136
+
137
+ disconnectedCallback() {
138
+ this._connected = false;
139
+ this._loadController?.abort();
140
+ this._loadController = null;
141
+ this._loadingKey = "";
142
+ this._loadingPromise = null;
143
+ this.removeEventListener("click", this._onClick);
144
+ this.removeEventListener("change", this._onChange);
145
+ this.removeEventListener("keydown", this._onKeyDown);
146
+ this.removeEventListener("focusin", this._onFocusIn);
147
+ }
148
+
149
+ attributeChangedCallback() {
150
+ if (!this._connected || this._rendering) return;
151
+ this._syncModel();
152
+ this.render();
153
+ }
154
+
155
+ /** @public */
156
+ get locale() {
157
+ return resolveLocale(this.getAttribute("locale") || "");
158
+ }
159
+
160
+ set locale(value) {
161
+ if (value) this.setAttribute("locale", value);
162
+ else this.removeAttribute("locale");
163
+ }
164
+
165
+ /** @public */
166
+ get value() {
167
+ return this._model.value;
168
+ }
169
+
170
+ set value(value) {
171
+ const next = value || "";
172
+ if (next && !isDate(next)) throw new TypeError(`Invalid calendar value: ${next}`);
173
+ if (next === this._model.value) return;
174
+ this._model.setValue(next);
175
+ this._reflect("value", next);
176
+ if (this._connected) this.render();
177
+ }
178
+
179
+ /** @public */
180
+ get display() {
181
+ return this._model.display;
182
+ }
183
+
184
+ set display(value) {
185
+ this._setDisplay(value, { emit: false });
186
+ }
187
+
188
+ /** @public */
189
+ get focusedDate() {
190
+ return this._model.focused;
191
+ }
192
+
193
+ set focusedDate(value) {
194
+ const next = value || "";
195
+ if (next === this._model.focused) return;
196
+ this.focusDate(next, { moveFocus: false });
197
+ }
198
+
199
+ /** @public */
200
+ get min() {
201
+ const value = this.getAttribute("min") || "";
202
+ return isDate(value) ? value : "";
203
+ }
204
+
205
+ set min(value) {
206
+ if (value) this.setAttribute("min", value);
207
+ else this.removeAttribute("min");
208
+ }
209
+
210
+ /** @public */
211
+ get max() {
212
+ const value = this.getAttribute("max") || "";
213
+ return isDate(value) ? value : "";
214
+ }
215
+
216
+ set max(value) {
217
+ if (value) this.setAttribute("max", value);
218
+ else this.removeAttribute("max");
219
+ }
220
+
221
+ /** @public */
222
+ get firstDay() {
223
+ return parseFirstDay(this.getAttribute("first-day") || "1");
224
+ }
225
+
226
+ set firstDay(value) {
227
+ this.setAttribute("first-day", String(value));
228
+ }
229
+
230
+ /** @public */
231
+ get selection() {
232
+ return this.getAttribute("selection") === "none" ? "none" : "single";
233
+ }
234
+
235
+ set selection(value) {
236
+ this.setAttribute("selection", value === "none" ? "none" : "single");
237
+ }
238
+
239
+ /** @public */
240
+ get highlightedRange() {
241
+ return { ...this._highlightedRange };
242
+ }
243
+
244
+ set highlightedRange(value) {
245
+ const next = normalizeRange(value);
246
+ if (next.start === this._highlightedRange.start && next.end === this._highlightedRange.end) return;
247
+ this._highlightedRange = next;
248
+ if (this._connected) this.render();
249
+ }
250
+
251
+ /** @public */
252
+ get fixedWeeks() {
253
+ return this.hasAttribute("fixed-weeks");
254
+ }
255
+
256
+ set fixedWeeks(value) {
257
+ this.toggleAttribute("fixed-weeks", Boolean(value));
258
+ }
259
+
260
+ /** @public */
261
+ get showWeekNumbers() {
262
+ return this.hasAttribute("show-week-numbers");
263
+ }
264
+
265
+ set showWeekNumbers(value) {
266
+ this.toggleAttribute("show-week-numbers", Boolean(value));
267
+ }
268
+
269
+ /** @public */
270
+ get messages() {
271
+ return this._messages;
272
+ }
273
+
274
+ set messages(value) {
275
+ this._messages = { ...getDefaultMessages(), ...(value || {}) };
276
+ if (this._connected) this.render();
277
+ }
278
+
279
+ /** @public @returns {DateSource | null} */
280
+ get source() {
281
+ return this._source;
282
+ }
283
+
284
+ set source(value) {
285
+ this._source = value || null;
286
+ this.refreshSource();
287
+ }
288
+
289
+ /** @public @returns {DateStateResolver | null} */
290
+ get dateState() {
291
+ return this._dateState;
292
+ }
293
+
294
+ set dateState(value) {
295
+ this._dateState = typeof value === "function" ? value : null;
296
+ if (this._connected) this.render();
297
+ }
298
+
299
+ /** @public @returns {DayRenderer | null} */
300
+ get renderDay() {
301
+ return this._renderDay;
302
+ }
303
+
304
+ set renderDay(value) {
305
+ this._renderDay = typeof value === "function" ? value : null;
306
+ if (this._connected) this.render();
307
+ }
308
+
309
+ /** @public @returns {DateDisabledPredicate | null} */
310
+ get isDateDisabled() {
311
+ return this._isDateDisabled;
312
+ }
313
+
314
+ set isDateDisabled(value) {
315
+ this._isDateDisabled = typeof value === "function" ? value : null;
316
+ if (this._connected) this.render();
317
+ }
318
+
319
+ _syncModel() {
320
+ const firstDay = this.firstDay;
321
+ this._model.firstDay = firstDay;
322
+ const attrValue = this.getAttribute("value") || "";
323
+ if (!attrValue || isDate(attrValue)) this._model.value = attrValue;
324
+ const attrDisplay = this.getAttribute("display") || "";
325
+ if (attrDisplay) {
326
+ try {
327
+ this._model.setDisplay(attrDisplay);
328
+ } catch {
329
+ // Invalid author markup falls back to the current model display.
330
+ }
331
+ } else if (this._model.value) {
332
+ this._model.display = monthKey(this._model.value);
333
+ this._model.focused = this._model.value;
334
+ }
335
+ this._model.focused = this._clamp(this._model.focused);
336
+ if (!this.getAttribute("display")) this._model.display = monthKey(this._model.focused);
337
+ }
338
+
339
+ /** @param {string} name @param {string} value */
340
+ _reflect(name, value) {
341
+ this._rendering = true;
342
+ if (value) {
343
+ if (this.getAttribute(name) !== value) this.setAttribute(name, value);
344
+ } else if (this.hasAttribute(name)) {
345
+ this.removeAttribute(name);
346
+ }
347
+ this._rendering = false;
348
+ }
349
+
350
+ /** @param {string} value */
351
+ _clamp(value) {
352
+ return clampDate(value, this.min, this.max);
353
+ }
354
+
355
+ _weeks(display = this.display) {
356
+ const weeks = getMonthWeeks(display, { firstDay: this.firstDay });
357
+ if (!this.fixedWeeks || weeks.length >= 6) return weeks;
358
+ let cursor = weeks[weeks.length - 1]?.[6] || "";
359
+ while (weeks.length < 6 && cursor) {
360
+ const week = [];
361
+ for (let i = 1; i <= 7; i++) week.push(addDays(cursor, i));
362
+ weeks.push(week);
363
+ cursor = week[6];
364
+ }
365
+ return weeks;
366
+ }
367
+
368
+ /** @param {HTMLElement} element @returns {string} */
369
+ _focusTargetKey(element) {
370
+ if (element.matches(".dp-month-select, .dp-year-select, .dp-year-input") && element.id) {
371
+ return `#${CSS.escape(element.id)}`;
372
+ }
373
+ if (element.matches(".dp-nav[data-calendar-action]")) {
374
+ return `.dp-nav[data-calendar-action="${CSS.escape(element.dataset.calendarAction || "")}"]`;
375
+ }
376
+ if (element.matches(".dp-day[data-date]")) {
377
+ const date = element.getAttribute("data-date") || "";
378
+ if (isDate(date)) return `.dp-day[data-date="${date}"]`;
379
+ }
380
+ return "";
381
+ }
382
+
383
+ /** @param {string} [display] */
384
+ _range(display = this.display) {
385
+ const weeks = this._weeks(display);
386
+ const first = weeks[0];
387
+ const last = weeks[weeks.length - 1];
388
+ if (!first || !last) throw new Error("Calendar month produced no weeks");
389
+ return { start: first[0], end: last[6] };
390
+ }
391
+
392
+ /** @public @param {string} date */
393
+ getDateState(date) {
394
+ const fromSource = this._sourceStates.get(date) || {};
395
+ const local = this._dateState?.(date, { ...fromSource }) || {};
396
+ const state = { ...fromSource, ...local };
397
+ const outsideBounds =
398
+ (this.min && compareDates(date, this.min) < 0) || (this.max && compareDates(date, this.max) > 0);
399
+ const explicitlyEnabled = state.enabled === true;
400
+ // min/max > state.disabled > enabled:true lifts only isDateDisabled().
401
+ const disabledByRule = explicitlyEnabled ? false : Boolean(this._isDateDisabled?.(date));
402
+ const disabled = Boolean(outsideBounds) || Boolean(state.disabled) || disabledByRule;
403
+ return { ...state, disabled };
404
+ }
405
+
406
+ /** @public */
407
+ async refreshSource() {
408
+ this._loadController?.abort();
409
+ this._loadController = null;
410
+ this._loadingKey = "";
411
+ this._loadingPromise = null;
412
+ this._loadedRanges.clear();
413
+ this._sourceStates.clear();
414
+ if (this._connected) {
415
+ this.render();
416
+ await this._loadDisplay(this.display);
417
+ }
418
+ }
419
+
420
+ /** @public Ensure source state exists for the month containing a typed/selected date.
421
+ * @param {string} date @returns {Promise<boolean>} */
422
+ async ensureDate(date) {
423
+ if (!isDate(date) || !this._source) return true;
424
+ return (await this._loadDisplay(monthKey(date), false)) === "loaded";
425
+ }
426
+
427
+ /** @param {string} display @param {boolean} [rerender] @returns {Promise<"loaded" | "cancelled" | "failed">} */
428
+ async _loadDisplay(display, rerender = true) {
429
+ if (!this._source) return "loaded";
430
+ const range = this._range(display);
431
+ const key = `${range.start}/${range.end}`;
432
+ if (this._loadedRanges.has(key)) return "loaded";
433
+ if (key === this._loadingKey && this._loadingPromise) return this._loadingPromise;
434
+ this._loadController?.abort();
435
+ const controller = new AbortController();
436
+ this._loadController = controller;
437
+ this._loadingKey = key;
438
+ const source = this._source;
439
+ const promise = (async () => {
440
+ this.toggleAttribute("data-loading", true);
441
+ this.dispatchEvent(new CustomEvent("dateloadstart", { detail: range, bubbles: true }));
442
+ try {
443
+ const loader = typeof source === "function" ? source : source?.load?.bind(source);
444
+ if (!loader) throw new TypeError("Date source must be a function or expose load(range, { signal })");
445
+ const payload = await loader(range, { signal: controller.signal });
446
+ if (controller.signal.aborted) return "cancelled";
447
+ for (const [date, state] of normalizeDateStates(payload)) this._sourceStates.set(date, state);
448
+ this._loadedRanges.add(key);
449
+ this.dispatchEvent(new CustomEvent("dateloadend", { detail: range, bubbles: true }));
450
+ if (rerender && this._connected && monthKey(this.display) === monthKey(display)) this.render(false);
451
+ return "loaded";
452
+ } catch (error) {
453
+ if (controller.signal.aborted) return "cancelled";
454
+ this.dispatchEvent(new CustomEvent("dateloaderror", { detail: { ...range, error }, bubbles: true }));
455
+ return "failed";
456
+ } finally {
457
+ if (this._loadController === controller) {
458
+ this._loadController = null;
459
+ this.removeAttribute("data-loading");
460
+ }
461
+ if (this._loadingKey === key) {
462
+ this._loadingKey = "";
463
+ this._loadingPromise = null;
464
+ }
465
+ }
466
+ })();
467
+ this._loadingPromise = promise;
468
+ return promise;
469
+ }
470
+
471
+ /** @param {string} display @param {{emit?:boolean}} [options] */
472
+ _setDisplay(display, options = {}) {
473
+ const previous = this._model.display;
474
+ this._model.setDisplay(display);
475
+ this._model.focused = this._clamp(this._model.focused);
476
+ this._model.display = monthKey(this._model.focused);
477
+ this._reflect("display", this._model.display);
478
+ if (this._connected) this.render();
479
+ if (options.emit !== false && previous !== this._model.display) this._emitDisplayChange();
480
+ return this._model.display;
481
+ }
482
+
483
+ _emitDisplayChange() {
484
+ const range = this._range();
485
+ this.dispatchEvent(
486
+ new CustomEvent("displaychange", {
487
+ detail: { display: this.display, ...range },
488
+ bubbles: true,
489
+ }),
490
+ );
491
+ }
492
+
493
+ /** @public */
494
+ previousMonth() {
495
+ return this._setDisplay(shiftMonth(this.display, -1));
496
+ }
497
+
498
+ /** @public */
499
+ nextMonth() {
500
+ return this._setDisplay(shiftMonth(this.display, 1));
501
+ }
502
+
503
+ /** @public @param {string} date @param {{moveFocus?:boolean}} [options] */
504
+ focusDate(date, options = {}) {
505
+ if (!isDate(date)) throw new TypeError(`Invalid date: ${date}`);
506
+ const next = this._clamp(date);
507
+ const previousDisplay = this.display;
508
+ const previousFocused = this._model.focused;
509
+ const moveFocus = options.moveFocus !== false;
510
+ this._model.setFocused(next);
511
+ this._reflect("display", this._model.display);
512
+ if (previousDisplay !== this.display) {
513
+ if (this._connected) this.render();
514
+ this._emitDisplayChange();
515
+ } else if (this._connected && previousFocused !== this._model.focused) {
516
+ // Same month: no full render, just move the roving tabindex.
517
+ this._moveFocusDom(previousFocused, this._model.focused, moveFocus);
518
+ }
519
+ if (moveFocus) queueMicrotask(() => this.focusGrid());
520
+ }
521
+
522
+ /** @param {string} previous @param {string} next @param {boolean} [moveFocus] */
523
+ _moveFocusDom(previous, next, moveFocus = true) {
524
+ const prevCell = previous ? this.querySelector(`.dp-day[data-date="${CSS.escape(previous)}"]`) : null;
525
+ const nextCell = this.querySelector(`.dp-day[data-date="${CSS.escape(next)}"]`);
526
+ if (prevCell instanceof HTMLElement) prevCell.tabIndex = -1;
527
+ if (nextCell instanceof HTMLElement) {
528
+ nextCell.tabIndex = 0;
529
+ if (moveFocus) nextCell.focus();
530
+ } else if (moveFocus) {
531
+ this.focusGrid();
532
+ }
533
+ }
534
+
535
+ /** @param {string} previousValue @param {string} nextValue */
536
+ _moveSelectionDom(previousValue, nextValue) {
537
+ if (previousValue) {
538
+ const prev = this.querySelector(`.dp-day[data-date="${CSS.escape(previousValue)}"]`);
539
+ if (prev instanceof HTMLElement) {
540
+ prev.removeAttribute("data-selected");
541
+ prev.removeAttribute("aria-selected");
542
+ }
543
+ }
544
+ if (nextValue) {
545
+ const next = this.querySelector(`.dp-day[data-date="${CSS.escape(nextValue)}"]`);
546
+ if (next instanceof HTMLElement) {
547
+ next.setAttribute("data-selected", "true");
548
+ next.setAttribute("aria-selected", "true");
549
+ return true;
550
+ }
551
+ }
552
+ return false;
553
+ }
554
+
555
+ /** @public */
556
+ focusGrid() {
557
+ const target = this.querySelector(`.dp-day[data-date="${CSS.escape(this.focusedDate)}"]`);
558
+ if (target instanceof HTMLElement) target.focus();
559
+ }
560
+
561
+ /** @param {string} date @returns {Promise<boolean>} */
562
+ async _activate(date) {
563
+ // Guarantee remote state before deciding: a date clicked before the
564
+ // source resolves must not be treated as available, and a cancelled or
565
+ // failed load must not authorize it either.
566
+ const confirmed = await this.ensureDate(date);
567
+ const state = this.getDateState(date);
568
+ if (!confirmed || state.disabled) {
569
+ this.dispatchEvent(new CustomEvent("dateinvalid", { detail: { date, state }, bubbles: true }));
570
+ return false;
571
+ }
572
+ const accepted = this.dispatchEvent(
573
+ new CustomEvent("dateactivate", {
574
+ detail: { date, state },
575
+ bubbles: true,
576
+ cancelable: true,
577
+ }),
578
+ );
579
+ if (!accepted) return false;
580
+ if (this.selection === "single") {
581
+ const previousValue = this._model.value;
582
+ this._model.setValue(date);
583
+ this._reflect("value", date);
584
+ this.dispatchEvent(new CustomEvent("datechange", { detail: { value: date }, bubbles: true }));
585
+ this.dispatchEvent(new Event("change", { bubbles: true }));
586
+ if (this._connected) {
587
+ if (monthKey(date) === this.display) this._moveSelectionDom(previousValue, date);
588
+ else this.render();
589
+ }
590
+ } else if (this._connected) {
591
+ // selection="none": keep activation observable without touching value.
592
+ if (monthKey(date) !== this.display) this.render();
593
+ }
594
+ return true;
595
+ }
596
+
597
+ /** @param {MouseEvent} event */
598
+ _onClick(event) {
599
+ const target = event.target;
600
+ if (!(target instanceof Element)) return;
601
+ const nav = target.closest("[data-calendar-action]");
602
+ if (nav instanceof HTMLButtonElement) {
603
+ const action = nav.dataset.calendarAction;
604
+ if (action === "previous") this.previousMonth();
605
+ if (action === "next") this.nextMonth();
606
+ return;
607
+ }
608
+ const cell = target.closest(".dp-day[data-date]");
609
+ if (!(cell instanceof HTMLTableCellElement)) return;
610
+ const date = cell.dataset.date || "";
611
+ if (!isDate(date)) return;
612
+ const previousDisplay = this.display;
613
+ this._model.setFocused(this._clamp(date));
614
+ this._reflect("display", this._model.display);
615
+ if (previousDisplay !== this.display) this._emitDisplayChange();
616
+ void this._activate(date);
617
+ }
618
+
619
+ /** @param {Event} event */
620
+ _onChange(event) {
621
+ const target = event.target;
622
+ if (target instanceof HTMLSelectElement && target.matches(".dp-month-select")) {
623
+ this._setDisplay(`${this.display.slice(0, 4)}-${target.value}`);
624
+ return;
625
+ }
626
+ if (target instanceof HTMLSelectElement && target.matches(".dp-year-select")) {
627
+ this._setDisplay(`${target.value}-${this.display.slice(5, 7)}`);
628
+ return;
629
+ }
630
+ if (target instanceof HTMLInputElement && target.matches(".dp-year-input")) {
631
+ const year = Number(target.value);
632
+ if (!Number.isInteger(year) || year < 1 || year > 9999) {
633
+ if (this._connected) this.render(false);
634
+ return;
635
+ }
636
+ this._setDisplay(`${String(year).padStart(4, "0")}-${this.display.slice(5, 7)}`);
637
+ }
638
+ }
639
+
640
+ /** @param {FocusEvent} event */
641
+ _onFocusIn(event) {
642
+ const target = event.target;
643
+ if (!(target instanceof HTMLTableCellElement) || !target.matches(".dp-day[data-date]")) return;
644
+ const date = target.dataset.date || "";
645
+ if (isDate(date)) this._model.focused = date;
646
+ }
647
+
648
+ /** @param {KeyboardEvent} event */
649
+ _onKeyDown(event) {
650
+ const target = event.target;
651
+ if (!(target instanceof HTMLTableCellElement) || !target.matches(".dp-day[data-date]")) return;
652
+ const date = target.dataset.date || this.focusedDate;
653
+ if (event.key === "Enter" || event.key === " ") {
654
+ event.preventDefault();
655
+ void this._activate(date);
656
+ return;
657
+ }
658
+ // Movement primitives live in CalendarModel; the DOM only syncs render/focus.
659
+ const anchorFocused = this._model.focused;
660
+ const anchorDisplay = this._model.display;
661
+ this._model.firstDay = this.firstDay;
662
+ this._model.setFocused(date, { follow: false });
663
+ if (event.key === "ArrowLeft") this._model.moveFocusDays(-1);
664
+ else if (event.key === "ArrowRight") this._model.moveFocusDays(1);
665
+ else if (event.key === "ArrowUp") this._model.moveFocusDays(-7);
666
+ else if (event.key === "ArrowDown") this._model.moveFocusDays(7);
667
+ else if (event.key === "Home") this._model.focusWeekStart();
668
+ else if (event.key === "End") this._model.focusWeekEnd();
669
+ else if (event.key === "PageUp")
670
+ event.shiftKey ? this._model.moveFocusYears(-1) : this._model.moveFocusMonths(-1);
671
+ else if (event.key === "PageDown")
672
+ event.shiftKey ? this._model.moveFocusYears(1) : this._model.moveFocusMonths(1);
673
+ else return;
674
+ const next = this._model.focused;
675
+ // Restore the pre-navigation state; focusDate() decides follow/render.
676
+ this._model.focused = anchorFocused;
677
+ this._model.display = anchorDisplay;
678
+ if (!next) return;
679
+ event.preventDefault();
680
+ this.focusDate(this._clamp(next));
681
+ }
682
+
683
+ /** @public @param {boolean} [load] */
684
+ render(load = true) {
685
+ if (!this._connected) return;
686
+ const locale = this.locale;
687
+ const display = this.display;
688
+ const displayYear = yearOf(display);
689
+ const displayMonth = Number(display.slice(5, 7));
690
+ const names = monthNames(locale, "long");
691
+ const weekdayShort = weekdayNames(locale, this.firstDay, "short");
692
+ const weekdayLong = weekdayNames(locale, this.firstDay, "long");
693
+ const weeks = this._weeks();
694
+ const today = todayISO();
695
+ const headingId = `${this._id}-heading`;
696
+ const gridId = `${this._id}-grid`;
697
+ const minMonth = this.min ? monthKey(this.min) : "";
698
+ const maxMonth = this.max ? monthKey(this.max) : "";
699
+ const prevDisabled = Boolean(minMonth && shiftMonth(display, -1) < minMonth);
700
+ const nextDisabled = Boolean(maxMonth && shiftMonth(display, 1) > maxMonth);
701
+ const minYearAttr = this.min ? ` min="${Number(this.min.slice(0, 4))}"` : "";
702
+ const maxYearAttr = this.max ? ` max="${Number(this.max.slice(0, 4))}"` : "";
703
+ const showWeeks = this.showWeekNumbers && this.firstDay === 1;
704
+ const weekHeader = showWeeks
705
+ ? `<th scope="col" class="dp-week-heading" abbr="${escapeHtml(this._messages.week)}">#</th>`
706
+ : "";
707
+
708
+ const monthOptions = names
709
+ .map((name, index) => {
710
+ const month = index + 1;
711
+ const key = `${String(displayYear).padStart(4, "0")}-${String(month).padStart(2, "0")}`;
712
+ const disabled = (minMonth && key < minMonth) || (maxMonth && key > maxMonth);
713
+ return `<option value="${String(month).padStart(2, "0")}"${month === displayMonth ? " selected" : ""}${disabled ? " disabled" : ""}>${escapeHtml(name)}</option>`;
714
+ })
715
+ .join("");
716
+
717
+ const dayHeaders = weekdayShort
718
+ .map(
719
+ (name, index) =>
720
+ `<th scope="col" abbr="${escapeHtml(weekdayLong[index])}">${escapeHtml(name.replace(".", ""))}</th>`,
721
+ )
722
+ .join("");
723
+
724
+ const rows = weeks
725
+ .map((week) => {
726
+ const weekNumber = showWeeks
727
+ ? `<th scope="row" class="dp-week-number">${isoWeekNumber(week[0])}</th>`
728
+ : "";
729
+ const cells = week
730
+ .map((date) => {
731
+ const state = this.getDateState(date);
732
+ const outside = monthKey(date) !== display;
733
+ const selected = this.value === date;
734
+ const focused = this.focusedDate === date;
735
+ const isToday = date === today;
736
+ const position = rangePosition(date, this._highlightedRange);
737
+ const rangeLabel = rangeLabelKey(position);
738
+ const description = typeof state.description === "string" ? state.description : "";
739
+ const label = [
740
+ formatLongDate(date, locale),
741
+ description,
742
+ rangeLabel ? this._messages[rangeLabel] : "",
743
+ state.disabled ? this._messages.unavailable : "",
744
+ ]
745
+ .filter(Boolean)
746
+ .join(". ");
747
+ 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>`;
748
+ })
749
+ .join("");
750
+ return `<tr>${weekNumber}${cells}</tr>`;
751
+ })
752
+ .join("");
753
+
754
+ const focusedElement = document.activeElement;
755
+ const focusKey =
756
+ focusedElement instanceof HTMLElement && this.contains(focusedElement)
757
+ ? this._focusTargetKey(focusedElement)
758
+ : "";
759
+
760
+ this._rendering = true;
761
+ this.innerHTML = `
762
+ <div class="dp-calendar-shell">
763
+ <div class="dp-calendar-header">
764
+ <label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
765
+ <select id="${this._id}-month" class="dp-month-select" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>
766
+ <label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
767
+ <input id="${this._id}-year" class="dp-year-input" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">
768
+ <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="m10 4-4 4 4 4"/></svg></button>
769
+ <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="m6 4 4 4-4 4"/></svg></button>
770
+ </div>
771
+ <h2 id="${headingId}" class="dp-calendar-heading" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
772
+ <table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
773
+ <thead><tr>${weekHeader}${dayHeaders}</tr></thead>
774
+ <tbody>${rows}</tbody>
775
+ </table>
776
+ </div>`;
777
+ this._rendering = false;
778
+
779
+ if (focusKey) {
780
+ queueMicrotask(() => {
781
+ const target = this.querySelector(focusKey);
782
+ if (target instanceof HTMLElement) target.focus();
783
+ });
784
+ }
785
+
786
+ if (this._renderDay) {
787
+ for (const cell of this.querySelectorAll(".dp-day[data-date]")) {
788
+ if (!(cell instanceof HTMLTableCellElement)) continue;
789
+ const date = cell.dataset.date || "";
790
+ const extra = cell.querySelector(".dp-day-extra");
791
+ if (!(extra instanceof HTMLElement)) continue;
792
+ const content = this._renderDay(date, this.getDateState(date));
793
+ if (content instanceof Node) extra.append(content);
794
+ else if (content !== undefined && content !== null) extra.textContent = String(content);
795
+ }
796
+ }
797
+
798
+ if (load) void this._loadDisplay(display);
799
+ }
800
+ }