@lekoala/date-picker 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,14 @@
1
+ import { activationIntents } from "./activation-intents.js";
1
2
  import { CalendarModel } from "./calendar-model.js";
2
- import { getDefaultControlClasses, resolveControlClasses, sameControlClasses } from "./control-classes.js";
3
+ import {
4
+ getDefaultControlClasses,
5
+ normalizeControlClasses,
6
+ resolveControlClasses,
7
+ sameControlClasses,
8
+ setDefaultControlClasses,
9
+ subscribeDefaultControlClasses,
10
+ usesDefaultControlClasses,
11
+ } from "./control-classes.js";
3
12
  import {
4
13
  addDays,
5
14
  clampDate,
@@ -21,8 +30,9 @@ import {
21
30
  resolveLocale,
22
31
  weekdayNames,
23
32
  } from "./intl.js";
24
- import { getDefaultMessages } from "./messages.js";
33
+ import { getDefaultMessages, setDefaultMessages, subscribeDefaultMessages } from "./messages.js";
25
34
  import { normalizeDateStates } from "./source.js";
35
+ import { upgradeProperties } from "./upgrade-properties.js";
26
36
 
27
37
  let uid = 0;
28
38
 
@@ -128,6 +138,26 @@ export class DateCalendarElement extends HTMLElement {
128
138
  "month-format",
129
139
  ];
130
140
 
141
+ /** @public @param {Partial<import("./control-classes.js").ControlClasses>} classes */
142
+ static setDefaultControlClasses(classes) {
143
+ setDefaultControlClasses(classes);
144
+ }
145
+
146
+ /** @public */
147
+ static getDefaultControlClasses() {
148
+ return getDefaultControlClasses();
149
+ }
150
+
151
+ /** @public @param {Partial<typeof import("./messages.js").DEFAULT_MESSAGES>} messages */
152
+ static setDefaultMessages(messages) {
153
+ setDefaultMessages(messages);
154
+ }
155
+
156
+ /** @public */
157
+ static getDefaultMessages() {
158
+ return getDefaultMessages();
159
+ }
160
+
131
161
  constructor() {
132
162
  super();
133
163
  this._id = `date-calendar-${++uid}`;
@@ -136,6 +166,9 @@ export class DateCalendarElement extends HTMLElement {
136
166
  this._rendering = false;
137
167
  /** @type {DateSource | null} */
138
168
  this._source = null;
169
+ /** Intent the host attached to the next activation of `date`.
170
+ * @type {{date: string, intent: unknown} | null} */
171
+ this._hostIntent = null;
139
172
  this._sourceStates = new Map();
140
173
  this._loadedRanges = new Set();
141
174
  this._loadController = null;
@@ -158,8 +191,14 @@ export class DateCalendarElement extends HTMLElement {
158
191
  this._paintedPreview = false;
159
192
  /** @type {Map<string, DateState>} States of the current rendered grid. */
160
193
  this._renderedStates = new Map();
161
- this._messages = getDefaultMessages();
162
- this._controlClasses = getDefaultControlClasses();
194
+ /** @type {typeof import("./messages.js").DEFAULT_MESSAGES | null} */
195
+ this._messages = null;
196
+ /** @type {Partial<import("./control-classes.js").ControlClasses> | null} */
197
+ this._controlClasses = null;
198
+ /** @type {(() => void) | null} */
199
+ this._stopDefaultMessages = null;
200
+ /** @type {(() => void) | null} */
201
+ this._stopDefaultControlClasses = null;
163
202
  this._onClick = this._onClick.bind(this);
164
203
  this._onChange = this._onChange.bind(this);
165
204
  this._onKeyDown = this._onKeyDown.bind(this);
@@ -168,16 +207,46 @@ export class DateCalendarElement extends HTMLElement {
168
207
 
169
208
  connectedCallback() {
170
209
  if (this._connected) return;
210
+ upgradeProperties(this, [
211
+ "locale",
212
+ "min",
213
+ "max",
214
+ "firstDay",
215
+ "selection",
216
+ "monthFormat",
217
+ "fixedWeeks",
218
+ "showWeekNumbers",
219
+ "controlClasses",
220
+ "messages",
221
+ "source",
222
+ "dateState",
223
+ "renderDay",
224
+ "isDateDisabled",
225
+ "highlightedRange",
226
+ "value",
227
+ "display",
228
+ ]);
229
+ this._syncModel();
230
+ upgradeProperties(this, ["focusedDate"]);
231
+ this._stopDefaultControlClasses = subscribeDefaultControlClasses(() => {
232
+ if (usesDefaultControlClasses(this._controlClasses)) this.render();
233
+ });
234
+ this._stopDefaultMessages = subscribeDefaultMessages(() => {
235
+ if (this._messages === null) this.render();
236
+ });
171
237
  this._connected = true;
172
238
  this.addEventListener("click", this._onClick);
173
239
  this.addEventListener("change", this._onChange);
174
240
  this.addEventListener("keydown", this._onKeyDown);
175
241
  this.addEventListener("focusin", this._onFocusIn);
176
- this._syncModel();
177
242
  this.render();
178
243
  }
179
244
 
180
245
  disconnectedCallback() {
246
+ this._stopDefaultControlClasses?.();
247
+ this._stopDefaultControlClasses = null;
248
+ this._stopDefaultMessages?.();
249
+ this._stopDefaultMessages = null;
181
250
  this._connected = false;
182
251
  this._loadController?.abort();
183
252
  this._loadController = null;
@@ -328,29 +397,30 @@ export class DateCalendarElement extends HTMLElement {
328
397
  }
329
398
 
330
399
  /**
331
- * Author classes for header controls; merged with global defaults.
400
+ * Author classes for header controls; omitted roles follow current global defaults.
332
401
  * @public
333
402
  * @returns {import("./control-classes.js").ControlClasses}
334
403
  */
335
404
  get controlClasses() {
336
- return { ...this._controlClasses };
405
+ return resolveControlClasses(this._controlClasses);
337
406
  }
338
407
 
339
408
  /** @param {Partial<import("./control-classes.js").ControlClasses> | null} value */
340
409
  set controlClasses(value) {
341
- const classes = resolveControlClasses(value);
342
- if (sameControlClasses(classes, this._controlClasses)) return;
343
- this._controlClasses = classes;
410
+ const previous = this.controlClasses;
411
+ this._controlClasses = normalizeControlClasses(value);
412
+ if (sameControlClasses(previous, this.controlClasses)) return;
344
413
  if (this._connected) this.render();
345
414
  }
346
415
 
347
- /** @public */
416
+ /** @public @returns {typeof import("./messages.js").DEFAULT_MESSAGES} */
348
417
  get messages() {
349
- return this._messages;
418
+ return this._messages ?? getDefaultMessages();
350
419
  }
351
420
 
421
+ /** @param {Partial<typeof import("./messages.js").DEFAULT_MESSAGES> | null} value */
352
422
  set messages(value) {
353
- this._messages = { ...getDefaultMessages(), ...(value || {}) };
423
+ this._messages = value == null ? null : { ...getDefaultMessages(), ...value };
354
424
  if (this._connected) this.render();
355
425
  }
356
426
 
@@ -471,7 +541,7 @@ export class DateCalendarElement extends HTMLElement {
471
541
  return { start: first[0], end: last[6] };
472
542
  }
473
543
 
474
- /** @public @param {string} date */
544
+ /** @public @param {string} date @returns {DateState & {disabled: boolean}} */
475
545
  getDateState(date) {
476
546
  const fromSource = this._sourceStates.get(date) || {};
477
547
  const local = this._dateState?.(date, { ...fromSource }) || {};
@@ -615,6 +685,16 @@ export class DateCalendarElement extends HTMLElement {
615
685
  }
616
686
  }
617
687
 
688
+ /** Leave exactly one roving tab stop, on `date`'s cell when rendered.
689
+ * @param {string} date */
690
+ _syncRovingDom(date) {
691
+ for (const cell of this.querySelectorAll('.dp-day[tabindex="0"]')) {
692
+ if (cell instanceof HTMLElement && cell.dataset.date !== date) cell.tabIndex = -1;
693
+ }
694
+ const cell = this.querySelector(`.dp-day[data-date="${CSS.escape(date)}"]`);
695
+ if (cell instanceof HTMLElement) cell.tabIndex = 0;
696
+ }
697
+
618
698
  /** @param {string} previousValue @param {string} nextValue */
619
699
  _moveSelectionDom(previousValue, nextValue) {
620
700
  if (previousValue) {
@@ -648,8 +728,8 @@ export class DateCalendarElement extends HTMLElement {
648
728
  return [
649
729
  formatLongDate(date, this.locale),
650
730
  description,
651
- rangeLabel ? this._messages[rangeLabel] : "",
652
- state.disabled ? this._messages.unavailable : "",
731
+ rangeLabel ? this.messages[rangeLabel] : "",
732
+ state.disabled ? this.messages.unavailable : "",
653
733
  ]
654
734
  .filter(Boolean)
655
735
  .join(". ");
@@ -701,21 +781,30 @@ export class DateCalendarElement extends HTMLElement {
701
781
  * @param {string} date @returns {Promise<boolean>}
702
782
  */
703
783
  async activateDate(date) {
784
+ const hostIntent = this._hostIntent?.date === date ? this._hostIntent.intent : undefined;
785
+ this._hostIntent = null;
786
+ /** @param {Event} event */
787
+ const tagged = (event) => {
788
+ if (hostIntent !== undefined) activationIntents.set(event, hostIntent);
789
+ return event;
790
+ };
704
791
  // Guarantee remote state before deciding: a date clicked before the
705
792
  // source resolves must not be treated as available, and a cancelled or
706
793
  // failed load must not authorize it either.
707
794
  const confirmed = await this.ensureDate(date);
708
795
  const state = this.getDateState(date);
709
796
  if (!confirmed || state.disabled) {
710
- this.dispatchEvent(new CustomEvent("dateinvalid", { detail: { date, state }, bubbles: true }));
797
+ this.dispatchEvent(tagged(new CustomEvent("dateinvalid", { detail: { date, state }, bubbles: true })));
711
798
  return false;
712
799
  }
713
800
  const accepted = this.dispatchEvent(
714
- new CustomEvent("dateactivate", {
715
- detail: { date, state },
716
- bubbles: true,
717
- cancelable: true,
718
- }),
801
+ tagged(
802
+ new CustomEvent("dateactivate", {
803
+ detail: { date, state },
804
+ bubbles: true,
805
+ cancelable: true,
806
+ }),
807
+ ),
719
808
  );
720
809
  if (!accepted) return false;
721
810
  if (this.selection === "single") {
@@ -754,6 +843,8 @@ export class DateCalendarElement extends HTMLElement {
754
843
  this._model.setFocused(this._clamp(date));
755
844
  this._reflect("display", this._model.display);
756
845
  if (previousDisplay !== this.display) this._emitDisplayChange();
846
+ // Engines that do not focus a clicked cell still need the tab stop moved.
847
+ else this._syncRovingDom(this._model.focused);
757
848
  void this.activateDate(date);
758
849
  }
759
850
 
@@ -766,9 +857,8 @@ export class DateCalendarElement extends HTMLElement {
766
857
  }
767
858
  if (target instanceof HTMLInputElement && target.matches(".dp-year-input")) {
768
859
  const year = Number(target.value);
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) {
860
+ // 9999-12 overflows into 10000, but _setDisplay() clamps it to 9999-11.
861
+ if (!Number.isInteger(year) || year < 1 || year > 9999) {
772
862
  if (this._connected) this.render(false);
773
863
  return;
774
864
  }
@@ -789,6 +879,8 @@ export class DateCalendarElement extends HTMLElement {
789
879
  const date = target.dataset.date || "";
790
880
  if (!isDate(date)) return;
791
881
  this._model.focused = date;
882
+ // A pointer focuses the cell directly; keep exactly one tab stop on it.
883
+ this._syncRovingDom(date);
792
884
  this.dispatchEvent(
793
885
  new CustomEvent("datefocus", { detail: { date, state: this.getDateState(date) }, bubbles: true }),
794
886
  );
@@ -849,11 +941,11 @@ export class DateCalendarElement extends HTMLElement {
849
941
  const nextKey = stepMonthKey(display, 1);
850
942
  const prevDisabled = Boolean(minMonth && prevKey !== null && prevKey < minMonth);
851
943
  const nextDisabled = Boolean(maxMonth && nextKey !== null && nextKey > maxMonth);
852
- const minYearAttr = this.min ? ` min="${Number(this.min.slice(0, 4))}"` : "";
853
- const maxYearAttr = this.max ? ` max="${Number(this.max.slice(0, 4))}"` : "";
944
+ const minYearAttr = ` min="${this.min ? Number(this.min.slice(0, 4)) : 1}"`;
945
+ const maxYearAttr = ` max="${this.max ? Number(this.max.slice(0, 4)) : 9999}"`;
854
946
  const showWeeks = this.showWeekNumbers && this.firstDay === 1;
855
947
  const weekHeader = showWeeks
856
- ? `<th scope="col" class="dp-week-heading" abbr="${escapeHtml(this._messages.week)}">#</th>`
948
+ ? `<th scope="col" class="dp-week-heading" abbr="${escapeHtml(this.messages.week)}">#</th>`
857
949
  : "";
858
950
 
859
951
  const monthOptions = names
@@ -906,14 +998,14 @@ export class DateCalendarElement extends HTMLElement {
906
998
  // select via `data-year-first`.
907
999
  const yearFirst = monthYearOrder(locale)[0] === "year";
908
1000
  this.toggleAttribute("data-year-first", yearFirst);
909
- const classes = this._controlClasses;
1001
+ const classes = this.controlClasses;
910
1002
  const monthClass = escapeHtml(classes.month || "dp-skin");
911
1003
  const yearClass = escapeHtml(classes.year || "dp-skin");
912
1004
  const navClass = escapeHtml(classes.nav || "dp-skin");
913
- const monthControl = `<label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
914
- <select id="${this._id}-month" class="dp-month-select ${monthClass}" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>`;
915
- const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
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)}">`;
1005
+ const monthControl = `<label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this.messages.month)}</label>
1006
+ <select id="${this._id}-month" class="dp-month-select ${monthClass}" aria-label="${escapeHtml(this.messages.month)}">${monthOptions}</select>`;
1007
+ const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this.messages.year)}</label>
1008
+ <input id="${this._id}-year" class="dp-year-input ${yearClass}" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this.messages.year)}">`;
917
1009
 
918
1010
  this._rendering = true;
919
1011
  this.innerHTML = `
@@ -921,8 +1013,8 @@ export class DateCalendarElement extends HTMLElement {
921
1013
  <div class="dp-calendar-header">
922
1014
  ${yearFirst ? yearControl : monthControl}
923
1015
  ${yearFirst ? monthControl : yearControl}
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>
1016
+ <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>
1017
+ <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>
926
1018
  </div>
927
1019
  <h2 id="${headingId}" class="dp-calendar-heading dp-visually-hidden" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
928
1020
  <table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
package/src/date-field.js CHANGED
@@ -1,6 +1,15 @@
1
1
  import { isDate } from "./date.js";
2
2
  import { createDateAdapter } from "./intl.js";
3
3
 
4
+ /**
5
+ * Whether the user may edit this input right now. `:disabled` is the effective
6
+ * state: the `disabled` property misses a disabled ancestor `<fieldset>`.
7
+ * @param {HTMLInputElement} input
8
+ */
9
+ export function isEditable(input) {
10
+ return !input.matches(":disabled") && !input.readOnly;
11
+ }
12
+
4
13
  /**
5
14
  * Owns a single editable form field inside a picker: the visible input, its
6
15
  * hidden canonical ISO field, the Intl adapter and the text commit pipeline
@@ -38,11 +47,33 @@ export class DateFieldController {
38
47
  * @type {((date: string, options?: {moveDisplay?: boolean}) => Promise<{ok: boolean, message?: string}>) | null}
39
48
  */
40
49
  this.confirm = null;
50
+ /** Canonical value behind a `defaultValue` the enhancement generated, or null. @type {string | null} */
51
+ this._generatedDefault = null;
41
52
  }
42
53
 
43
54
  /** @param {string} locale */
44
55
  setLocale(locale) {
56
+ const generated = this._generatedDefault;
57
+ const previous = generated ? this.adapter.format(generated) : null;
45
58
  this.adapter = createDateAdapter(locale || "");
59
+ // A generated default is localized text: reformat it so a later form reset
60
+ // never parses the old locale's text with the new adapter. An app-changed
61
+ // default no longer matches and stays untouched.
62
+ if (generated && this.input.defaultValue === previous) {
63
+ this.input.defaultValue = this.adapter.format(generated);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Make the current text the reset default when the author provided none.
69
+ * Never redefine an author-provided `input.defaultValue`: the next reset must
70
+ * use the native default (which the app may change).
71
+ */
72
+ adoptDefault() {
73
+ const input = this.input;
74
+ if (input.getAttribute("value") != null || input.defaultValue !== "") return;
75
+ input.defaultValue = input.value;
76
+ this._generatedDefault = this.canonical && input.value ? this.canonical : null;
46
77
  }
47
78
 
48
79
  /** @param {Record<string, string>} messages */
@@ -65,7 +96,7 @@ export class DateFieldController {
65
96
  }
66
97
 
67
98
  get isDisabled() {
68
- return this.input.disabled;
99
+ return this.input.matches(":disabled");
69
100
  }
70
101
 
71
102
  get isReadOnly() {