@lekoala/date-picker 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { activationIntents } from "./activation-intents.js";
1
2
  import { CalendarModel } from "./calendar-model.js";
2
3
  import {
3
4
  getDefaultControlClasses,
@@ -162,9 +163,18 @@ export class DateCalendarElement extends HTMLElement {
162
163
  this._id = `date-calendar-${++uid}`;
163
164
  this._model = new CalendarModel();
164
165
  this._connected = false;
166
+ /** Connect/disconnect generation guarding deferred work. */
167
+ this._lifecycleId = 0;
165
168
  this._rendering = false;
169
+ /** Persistent live heading; render() updates its text after it is attached. @type {HTMLHeadingElement | null} */
170
+ this._heading = null;
171
+ /** Replaceable visual subtree kept separate from the persistent live heading. @type {HTMLDivElement | null} */
172
+ this._renderRoot = null;
166
173
  /** @type {DateSource | null} */
167
174
  this._source = null;
175
+ /** Intent the host attached to the next activation of `date`.
176
+ * @type {{date: string, intent: unknown} | null} */
177
+ this._hostIntent = null;
168
178
  this._sourceStates = new Map();
169
179
  this._loadedRanges = new Set();
170
180
  this._loadController = null;
@@ -203,6 +213,7 @@ export class DateCalendarElement extends HTMLElement {
203
213
 
204
214
  connectedCallback() {
205
215
  if (this._connected) return;
216
+ this._lifecycleId++;
206
217
  upgradeProperties(this, [
207
218
  "locale",
208
219
  "min",
@@ -244,6 +255,7 @@ export class DateCalendarElement extends HTMLElement {
244
255
  this._stopDefaultMessages?.();
245
256
  this._stopDefaultMessages = null;
246
257
  this._connected = false;
258
+ this._lifecycleId++;
247
259
  this._loadController?.abort();
248
260
  this._loadController = null;
249
261
  this._loadingKey = "";
@@ -254,12 +266,43 @@ export class DateCalendarElement extends HTMLElement {
254
266
  this.removeEventListener("focusin", this._onFocusIn);
255
267
  }
256
268
 
257
- attributeChangedCallback() {
258
- if (!this._connected || this._rendering) return;
269
+ /** @param {string} name @param {string|null} _oldValue @param {string|null} newValue */
270
+ attributeChangedCallback(name, _oldValue, newValue) {
271
+ if (this._rendering) return;
272
+ if (name === "value" && newValue && !isDate(newValue)) {
273
+ this._reflect("value", this._model.value);
274
+ return;
275
+ }
276
+ if ((name === "min" || name === "max") && newValue && !isDate(newValue)) {
277
+ this._reflect(name, "");
278
+ if (this._connected) {
279
+ this._syncModel();
280
+ this.render();
281
+ }
282
+ return;
283
+ }
284
+ if (name === "display" && newValue) {
285
+ try {
286
+ if (monthKey(newValue) !== newValue) throw new TypeError("Non-canonical month");
287
+ } catch {
288
+ this._reflect("display", this._model.display);
289
+ return;
290
+ }
291
+ }
292
+ if (!this._connected) return;
259
293
  this._syncModel();
260
294
  this.render();
261
295
  }
262
296
 
297
+ /** Run a focus microtask only in the lifecycle that scheduled it. @param {() => void} callback */
298
+ _queueFocus(callback) {
299
+ const lifecycleId = this._lifecycleId;
300
+ queueMicrotask(() => {
301
+ if (!this._connected || lifecycleId !== this._lifecycleId) return;
302
+ callback();
303
+ });
304
+ }
305
+
263
306
  /** @public */
264
307
  get locale() {
265
308
  return resolveLocale(this.getAttribute("locale") || "");
@@ -537,7 +580,7 @@ export class DateCalendarElement extends HTMLElement {
537
580
  return { start: first[0], end: last[6] };
538
581
  }
539
582
 
540
- /** @public @param {string} date */
583
+ /** @public @param {string} date @returns {DateState & {disabled: boolean}} */
541
584
  getDateState(date) {
542
585
  const fromSource = this._sourceStates.get(date) || {};
543
586
  const local = this._dateState?.(date, { ...fromSource }) || {};
@@ -665,7 +708,7 @@ export class DateCalendarElement extends HTMLElement {
665
708
  // Same month: no full render, just move the roving tabindex.
666
709
  this._moveFocusDom(previousFocused, this._model.focused, moveFocus);
667
710
  }
668
- if (moveFocus) queueMicrotask(() => this.focusGrid());
711
+ if (moveFocus) this._queueFocus(() => this.focusGrid());
669
712
  }
670
713
 
671
714
  /** @param {string} previous @param {string} next @param {boolean} [moveFocus] */
@@ -681,6 +724,16 @@ export class DateCalendarElement extends HTMLElement {
681
724
  }
682
725
  }
683
726
 
727
+ /** Leave exactly one roving tab stop, on `date`'s cell when rendered.
728
+ * @param {string} date */
729
+ _syncRovingDom(date) {
730
+ for (const cell of this.querySelectorAll('.dp-day[tabindex="0"]')) {
731
+ if (cell instanceof HTMLElement && cell.dataset.date !== date) cell.tabIndex = -1;
732
+ }
733
+ const cell = this.querySelector(`.dp-day[data-date="${CSS.escape(date)}"]`);
734
+ if (cell instanceof HTMLElement) cell.tabIndex = 0;
735
+ }
736
+
684
737
  /** @param {string} previousValue @param {string} nextValue */
685
738
  _moveSelectionDom(previousValue, nextValue) {
686
739
  if (previousValue) {
@@ -767,21 +820,33 @@ export class DateCalendarElement extends HTMLElement {
767
820
  * @param {string} date @returns {Promise<boolean>}
768
821
  */
769
822
  async activateDate(date) {
823
+ if (!this._connected) return false;
824
+ const lifecycleId = this._lifecycleId;
825
+ const hostIntent = this._hostIntent?.date === date ? this._hostIntent.intent : undefined;
826
+ this._hostIntent = null;
827
+ /** @param {Event} event */
828
+ const tagged = (event) => {
829
+ if (hostIntent !== undefined) activationIntents.set(event, hostIntent);
830
+ return event;
831
+ };
770
832
  // Guarantee remote state before deciding: a date clicked before the
771
833
  // source resolves must not be treated as available, and a cancelled or
772
834
  // failed load must not authorize it either.
773
835
  const confirmed = await this.ensureDate(date);
836
+ if (!this._connected || lifecycleId !== this._lifecycleId) return false;
774
837
  const state = this.getDateState(date);
775
838
  if (!confirmed || state.disabled) {
776
- this.dispatchEvent(new CustomEvent("dateinvalid", { detail: { date, state }, bubbles: true }));
839
+ this.dispatchEvent(tagged(new CustomEvent("dateinvalid", { detail: { date, state }, bubbles: true })));
777
840
  return false;
778
841
  }
779
842
  const accepted = this.dispatchEvent(
780
- new CustomEvent("dateactivate", {
781
- detail: { date, state },
782
- bubbles: true,
783
- cancelable: true,
784
- }),
843
+ tagged(
844
+ new CustomEvent("dateactivate", {
845
+ detail: { date, state },
846
+ bubbles: true,
847
+ cancelable: true,
848
+ }),
849
+ ),
785
850
  );
786
851
  if (!accepted) return false;
787
852
  if (this.selection === "single") {
@@ -820,6 +885,8 @@ export class DateCalendarElement extends HTMLElement {
820
885
  this._model.setFocused(this._clamp(date));
821
886
  this._reflect("display", this._model.display);
822
887
  if (previousDisplay !== this.display) this._emitDisplayChange();
888
+ // Engines that do not focus a clicked cell still need the tab stop moved.
889
+ else this._syncRovingDom(this._model.focused);
823
890
  void this.activateDate(date);
824
891
  }
825
892
 
@@ -832,9 +899,8 @@ export class DateCalendarElement extends HTMLElement {
832
899
  }
833
900
  if (target instanceof HTMLInputElement && target.matches(".dp-year-input")) {
834
901
  const year = Number(target.value);
835
- // 9999 is a valid civil year but its December grid overflows into 10000,
836
- // so the editable range stops one year short of the unrenderable month.
837
- if (!Number.isInteger(year) || year < 1 || year > 9998) {
902
+ // 9999-12 overflows into 10000, but _setDisplay() clamps it to 9999-11.
903
+ if (!Number.isInteger(year) || year < 1 || year > 9999) {
838
904
  if (this._connected) this.render(false);
839
905
  return;
840
906
  }
@@ -855,6 +921,8 @@ export class DateCalendarElement extends HTMLElement {
855
921
  const date = target.dataset.date || "";
856
922
  if (!isDate(date)) return;
857
923
  this._model.focused = date;
924
+ // A pointer focuses the cell directly; keep exactly one tab stop on it.
925
+ this._syncRovingDom(date);
858
926
  this.dispatchEvent(
859
927
  new CustomEvent("datefocus", { detail: { date, state: this.getDateState(date) }, bubbles: true }),
860
928
  );
@@ -915,8 +983,8 @@ export class DateCalendarElement extends HTMLElement {
915
983
  const nextKey = stepMonthKey(display, 1);
916
984
  const prevDisabled = Boolean(minMonth && prevKey !== null && prevKey < minMonth);
917
985
  const nextDisabled = Boolean(maxMonth && nextKey !== null && nextKey > maxMonth);
918
- const minYearAttr = this.min ? ` min="${Number(this.min.slice(0, 4))}"` : "";
919
- const maxYearAttr = this.max ? ` max="${Number(this.max.slice(0, 4))}"` : "";
986
+ const minYearAttr = ` min="${this.min ? Number(this.min.slice(0, 4)) : 1}"`;
987
+ const maxYearAttr = ` max="${this.max ? Number(this.max.slice(0, 4)) : 9999}"`;
920
988
  const showWeeks = this.showWeekNumbers && this.firstDay === 1;
921
989
  const weekHeader = showWeeks
922
990
  ? `<th scope="col" class="dp-week-heading" abbr="${escapeHtml(this.messages.week)}">#</th>`
@@ -981,8 +1049,21 @@ export class DateCalendarElement extends HTMLElement {
981
1049
  const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this.messages.year)}</label>
982
1050
  <input id="${this._id}-year" class="dp-year-input ${yearClass}" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this.messages.year)}">`;
983
1051
 
1052
+ if (!this._heading?.isConnected || !this._renderRoot?.isConnected) {
1053
+ this.replaceChildren();
1054
+ const heading = document.createElement("h2");
1055
+ heading.id = headingId;
1056
+ heading.className = "dp-calendar-heading dp-visually-hidden";
1057
+ heading.setAttribute("aria-live", "polite");
1058
+ const renderRoot = document.createElement("div");
1059
+ renderRoot.className = "dp-calendar-render";
1060
+ this.append(heading, renderRoot);
1061
+ this._heading = heading;
1062
+ this._renderRoot = renderRoot;
1063
+ }
1064
+
984
1065
  this._rendering = true;
985
- this.innerHTML = `
1066
+ this._renderRoot.innerHTML = `
986
1067
  <div class="dp-calendar-shell">
987
1068
  <div class="dp-calendar-header">
988
1069
  ${yearFirst ? yearControl : monthControl}
@@ -990,18 +1071,18 @@ export class DateCalendarElement extends HTMLElement {
990
1071
  <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>
991
1072
  <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>
992
1073
  </div>
993
- <h2 id="${headingId}" class="dp-calendar-heading dp-visually-hidden" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
994
1074
  <table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
995
1075
  <thead><tr>${weekHeader}${dayHeaders}</tr></thead>
996
1076
  <tbody>${rows}</tbody>
997
1077
  </table>
998
1078
  </div>`;
1079
+ this._heading.textContent = formatMonthYear(`${display}-15`, locale);
999
1080
  this._rendering = false;
1000
1081
  this._paintedRange = { ...this._highlightedRange };
1001
1082
  this._paintedPreview = this.hasAttribute("data-range-preview");
1002
1083
 
1003
1084
  if (focusKey) {
1004
- queueMicrotask(() => {
1085
+ this._queueFocus(() => {
1005
1086
  let selector = focusKey;
1006
1087
  // A day-cell key goes stale across display changes: the old date can
1007
1088
  // reappear as outside-month padding in the new month (fixed-weeks or
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
@@ -26,6 +35,8 @@ export class DateFieldController {
26
35
  this.canonical = "";
27
36
  this._dirty = false;
28
37
  this._commitId = 0;
38
+ /** A torn-down controller is terminal: no commit may start or resume. */
39
+ this._disposed = false;
29
40
  this._originalName = input.getAttribute("name") || "";
30
41
  /** @type {MutationObserver | null} */
31
42
  this._attributeObserver = null;
@@ -38,11 +49,33 @@ export class DateFieldController {
38
49
  * @type {((date: string, options?: {moveDisplay?: boolean}) => Promise<{ok: boolean, message?: string}>) | null}
39
50
  */
40
51
  this.confirm = null;
52
+ /** Canonical value behind a `defaultValue` the enhancement generated, or null. @type {string | null} */
53
+ this._generatedDefault = null;
41
54
  }
42
55
 
43
56
  /** @param {string} locale */
44
57
  setLocale(locale) {
58
+ const generated = this._generatedDefault;
59
+ const previous = generated ? this.adapter.format(generated) : null;
45
60
  this.adapter = createDateAdapter(locale || "");
61
+ // A generated default is localized text: reformat it so a later form reset
62
+ // never parses the old locale's text with the new adapter. An app-changed
63
+ // default no longer matches and stays untouched.
64
+ if (generated && this.input.defaultValue === previous) {
65
+ this.input.defaultValue = this.adapter.format(generated);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Make the current text the reset default when the author provided none.
71
+ * Never redefine an author-provided `input.defaultValue`: the next reset must
72
+ * use the native default (which the app may change).
73
+ */
74
+ adoptDefault() {
75
+ const input = this.input;
76
+ if (input.getAttribute("value") != null || input.defaultValue !== "") return;
77
+ input.defaultValue = input.value;
78
+ this._generatedDefault = this.canonical && input.value ? this.canonical : null;
46
79
  }
47
80
 
48
81
  /** @param {Record<string, string>} messages */
@@ -65,7 +98,7 @@ export class DateFieldController {
65
98
  }
66
99
 
67
100
  get isDisabled() {
68
- return this.input.disabled;
101
+ return this.input.matches(":disabled");
69
102
  }
70
103
 
71
104
  get isReadOnly() {
@@ -148,34 +181,44 @@ export class DateFieldController {
148
181
  * `options` is forwarded to the `confirm` gate so a revalidation can ask it
149
182
  * not to move the calendar display (source reloads must not re-home it).
150
183
  * @param {{moveDisplay?: boolean}} [options]
151
- * @returns {Promise<{status: "ok" | "clear" | "invalid" | "stale", value?: string}>}
184
+ * @returns {Promise<{status: "ok" | "clear" | "invalid" | "stale", value?: string, commitId: number}>}
152
185
  */
153
186
  async commit(options = {}) {
154
187
  const input = this.input;
155
188
  const commitId = ++this._commitId;
189
+ if (this._disposed) return { status: "stale", commitId };
156
190
  const text = input.value.trim();
157
191
  if (!text) {
158
- if (commitId !== this._commitId) return { status: "stale" };
192
+ if (commitId !== this._commitId) return { status: "stale", commitId };
159
193
  input.setCustomValidity("");
160
- return { status: "clear", value: "" };
194
+ return { status: "clear", value: "", commitId };
161
195
  }
162
196
  const parsed = this.adapter.parse(text);
163
197
  if (!parsed) {
164
- if (commitId !== this._commitId) return { status: "stale" };
198
+ if (commitId !== this._commitId) return { status: "stale", commitId };
165
199
  if (this.hidden) this.hidden.value = "";
166
200
  input.setCustomValidity(this.messages.invalidDate);
167
- return { status: "invalid" };
201
+ return { status: "invalid", commitId };
168
202
  }
169
203
  if (this.confirm) {
170
204
  const result = await this.confirm(parsed, options);
171
- if (commitId !== this._commitId) return { status: "stale" };
205
+ // A disconnect tears this controller down while the confirm is pending;
206
+ // its result must not touch validity or the hidden field any more.
207
+ if (this._disposed || commitId !== this._commitId) return { status: "stale", commitId };
172
208
  if (!result?.ok) {
173
209
  if (this.hidden) this.hidden.value = "";
174
210
  input.setCustomValidity(result?.message || this.messages.unavailableDate);
175
- return { status: "invalid" };
211
+ return { status: "invalid", commitId };
176
212
  }
177
213
  }
178
- return { status: "ok", value: parsed };
214
+ return { status: "ok", value: parsed, commitId };
215
+ }
216
+
217
+ /** A coordinator must re-check this after awaiting commit(), including for
218
+ * synchronous clear/invalid results whose Promise resumes in a microtask.
219
+ * @param {number} commitId */
220
+ isCommitCurrent(commitId) {
221
+ return commitId === this._commitId;
179
222
  }
180
223
 
181
224
  /**
@@ -203,8 +246,12 @@ export class DateFieldController {
203
246
  }
204
247
 
205
248
  /** Remove the hidden field and attribute observer, restoring the original
206
- * authored `name` on the visible input. */
249
+ * authored `name` on the visible input. A disconnect is a teardown, not a
250
+ * pause: in-flight commits are invalidated and later ones are refused, so a
251
+ * reconnected picker rebuilds from the live DOM instead of resuming them. */
207
252
  teardown() {
253
+ this.dirty();
254
+ this._disposed = true;
208
255
  this._attributeObserver?.disconnect();
209
256
  this._attributeObserver = null;
210
257
  const hiddenName = this.hidden?.getAttribute("name") || this._originalName;