@lekoala/date-picker 0.5.2 → 0.6.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.
@@ -74,3 +74,100 @@ Both packages expose month-week derivation. They stay **deliberately duplicated*
74
74
  - `@lekoala/date-picker` owns civil string math (`dates.getMonthWeeks()`, `YYYY-MM-DD`).
75
75
 
76
76
  This keeps the two packages dependency-free from each other: a circular dependency between a picker and a scheduler would cost more than a few duplicated lines. `dates.getMonthWeeks()` keeps the scheduler's important contract (full civil weeks, configurable first day, 4–6 rows, no forced padding) so both stay interchangeable for the mini-calendar seam. A future shared `@lekoala/date-math` package is possible, but only as a separate proposal; it must not introduce a circular dependency.
77
+
78
+ ## App-owned range endpoint drag
79
+
80
+ `<date-picker range>` owns the range workflow, not pointer interaction ([D9](DECISIONS.md)). This recipe is a **tested starting point**, not a feature-equivalent replacement for the removed built-in drag: it shows the public seams and the guarantees they give, and leaves the gesture policy to the application.
81
+
82
+ ```js
83
+ const picker = document.querySelector("date-picker[range]");
84
+ const calendar = picker.calendar;
85
+ const inputs = {
86
+ start: picker.querySelector("input[data-range-start]"),
87
+ end: picker.querySelector("input[data-range-end]"),
88
+ };
89
+
90
+ const TRAVEL = 4; // px; below this a press never moves a bound
91
+ const isoAt = (x, y) => {
92
+ const element = document.elementFromPoint(x, y);
93
+ const cell = element instanceof Element ? element.closest("[data-date]") : null;
94
+ return cell?.getAttribute("data-date") || "";
95
+ };
96
+ const isEditable = (bound) =>
97
+ !inputs[bound].matches(":disabled") && !inputs[bound].readOnly;
98
+ const clamp = (date, bound, range) =>
99
+ bound === "start"
100
+ ? range.end && date > range.end ? range.end : date
101
+ : range.start && date < range.start ? range.start : date;
102
+
103
+ let armed = null;
104
+ let gesture = 0; // app-owned intent generation
105
+
106
+ const cancel = () => { armed = null; };
107
+
108
+ calendar.addEventListener("pointerdown", (event) => {
109
+ if (!event.isPrimary || event.button !== 0) return;
110
+ if (event.pointerType !== "mouse" && event.pointerType !== "pen") return;
111
+ armed = null; // a new primary press supersedes a stale gesture
112
+ const cell = event.target.closest("[data-date]");
113
+ const range = picker.range;
114
+ if (!cell || !range?.start || !range?.end) return;
115
+ const start = cell.hasAttribute("data-range-start");
116
+ const end = cell.hasAttribute("data-range-end");
117
+ if (start === end) return; // no handle on a one-day range
118
+ const bound = start ? "start" : "end";
119
+ if (!isEditable(bound)) return;
120
+ event.preventDefault(); // otherwise Firefox starts a text-selection drag
121
+ armed = { id: ++gesture, bound, x: event.clientX, y: event.clientY };
122
+ calendar.setPointerCapture(event.pointerId);
123
+ });
124
+
125
+ calendar.addEventListener("pointerup", async (event) => {
126
+ const drag = armed;
127
+ armed = null;
128
+ if (!drag) return;
129
+ if (calendar.hasPointerCapture(event.pointerId)) calendar.releasePointerCapture(event.pointerId);
130
+ const travelled =
131
+ Math.abs(event.clientX - drag.x) > TRAVEL || Math.abs(event.clientY - drag.y) > TRAVEL;
132
+ if (!travelled) return;
133
+ const range = picker.range;
134
+ const date = isoAt(event.clientX, event.clientY);
135
+ if (!date || !range?.start || !range?.end) return;
136
+ const next = clamp(date, drag.bound, range);
137
+ if (next === range[drag.bound]) return;
138
+ // Availability plus the cancelable dateactivate veto, in one public call.
139
+ if (!(await calendar.activateDate(next))) return;
140
+ // A newer gesture supersedes this one, and a concurrent range change wins.
141
+ if (drag.id !== gesture || !picker.open) return;
142
+ const current = picker.range;
143
+ if (!current || current.start !== range.start || current.end !== range.end) return;
144
+ picker.range = drag.bound === "start"
145
+ ? { start: next, end: range.end }
146
+ : { start: range.start, end: next };
147
+ inputs[drag.bound].dispatchEvent(new Event("input", { bubbles: true }));
148
+ inputs[drag.bound].dispatchEvent(new Event("change", { bubbles: true }));
149
+ });
150
+
151
+ calendar.addEventListener("pointercancel", cancel);
152
+ calendar.addEventListener("lostpointercapture", cancel);
153
+ ```
154
+
155
+ What the recipe implements, and `test/browser/app-owned-drag.spec.js` covers:
156
+
157
+ - a **travel threshold** (4 px): a press that does not travel far enough never moves a bound;
158
+ - a new primary press supersedes any stale armed gesture; `pointercancel` and `lostpointercapture` abandon it;
159
+ - pointer capture, so a drag that leaves the grid still ends correctly;
160
+ - the bound the gesture started on, clamped against the opposite bound;
161
+ - availability plus the cancelable `dateactivate` veto through `activateDate()`;
162
+ - an app-owned generation, so the older of two pending gestures never wins;
163
+ - a committed-range check after the await, so a programmatic or validation-driven `rangechange` during availability wins;
164
+ - one `rangechange`, plus native `input`/`change` only on the bound that moved.
165
+
166
+ What stays application-owned, and is **not** provided by this recipe:
167
+
168
+ - touch: the recipe handles mouse and pen only; add `touch-action`, a threshold and long-press policy if you need touch dragging;
169
+ - a cursor affordance: the removed `data-range-drag` styling is gone, style your own;
170
+ - a draft typed in the *other* bound: writing `picker.range` reformats both committed fields, so the draft is discarded;
171
+ - a mid-flight active-endpoint switch: the recipe applies the bound the gesture started on.
172
+
173
+ The component still guarantees the parts that belong to it: the activation is availability-checked and cancellable, and only a displayable (ordered) pair is ever forwarded to `highlightedRange`.
package/docs/DECISIONS.md CHANGED
@@ -54,12 +54,12 @@ Context: DOM-backed styling hooks become API the moment they are documented —
54
54
 
55
55
  Pointing and typing are different contracts, deliberately.
56
56
 
57
- - **Interaction** (click, keyboard, endpoint drag) always yields an ordered range. While a range is being created, the second selection is sorted against the anchor: `10` then `5` commits `5 -> 10`, and `10` then `10` a one-day range. No `dateinvalid` is fired merely because the second point is earlier.
57
+ - **Interaction** (click, keyboard) always yields an ordered range. While a range is being created, the second selection is sorted against the anchor: `10` then `5` commits `5 -> 10`, and `10` then `10` a one-day range. No `dateinvalid` is fired merely because the second point is earlier.
58
58
  - **Text entry** may be temporarily inverted. Typing `start` past `end` keeps both values and reports `rangeOrderStart` / `rangeOrderEnd` on the modified bound, because a half-typed pair is a normal editing state.
59
59
  - An explicit change to **one bound of an already complete pair** keeps that bound's identity: moving `end` before `start` is refused rather than silently rewriting the other field.
60
60
  - Because one interaction can move both bounds at once, a transition describes the whole resulting pair (`{ status, endpoint, changedEndpoints, range, activeEndpoint }`) and is applied in a single write: one `rangechange`, with synthetic `input` / `change` only on the bounds that actually moved.
61
- - Hover, keyboard focus and endpoint drag read the **same projection** (`previewRange()`) that the commit applies, so what the band promises is what the next selection commits. A preview writes nothing: no `rangechange`, no field write, no validation, no source request.
62
- - Click, keyboard and drop all end in that one range machine and in the same `ensureDate` plus cancelable `dateactivate` path, so an application veto cannot be bypassed by choosing another input device.
61
+ - Hover and keyboard focus read the **same projection** (`previewRange()`) that the commit applies, so what the band promises is what the next selection commits. A preview writes nothing: no `rangechange`, no field write, no validation, no source request.
62
+ - Click and keyboard end in that one range machine and in the same `ensureDate` plus cancelable `dateactivate` path, so an application veto cannot be bypassed by choosing another input device.
63
63
 
64
64
  ## D7 — Author classes own header control styling
65
65
 
@@ -72,3 +72,30 @@ The `.dp-*` namespace stays internal (D3); it is reserved and ignored in author
72
72
  Both element classes expose static `setDefaultControlClasses`, `getDefaultControlClasses`, `setDefaultMessages` and `getDefaultMessages` methods delegating to the existing module functions. This additive API makes defaults available in classic builds through `customElements.get()` without introducing a global. The constructors share one defaults state within a build. Instances without overrides resolve current defaults when used, and connected instances refresh when defaults change. Disconnected instances unsubscribe and read current defaults on reconnection. For `controlClasses`, an assignment stores only the normalized roles provided: omitted roles follow current global defaults at read/render time, an explicit empty string restores the library skin for that role, and `null` follows all defaults. Global class changes also refresh instances with partial overrides. Message assignments merge with current defaults and pin that instance setting; `null` resumes following global message defaults. Pickers forward only their own overrides to their calendar, preserving global inheritance.
73
73
 
74
74
  Writable public properties assigned before element definition are replayed through their setters on connection. Options are applied before building controls; picker `value` and `range` wait for the fields and range controller. The display, focus, activation and selection contracts remain distinct.
75
+
76
+ ## D9 — Endpoint dragging is an application interaction
77
+
78
+ `<date-picker range>` owns the range workflow: which bound a pick targets, sorting, atomic pair writes, validation and events. It does **not** implement pointer dragging of the two endpoints.
79
+
80
+ - A pointer gesture that moves a bound is a second interaction machine (pointer capture, travel threshold, `pointercancel`, `lostpointercapture`, trailing-click suppression, a per-gesture endpoint identity). It is not rejected as a feature; it sits at a different abstraction level from range semantics. The picker owns the range rules, not the pointer gesture.
81
+ - The application writes the public API, not internal state: `calendar.activateDate(date)` for availability plus the cancelable `dateactivate` veto, then `picker.range = { start, end }`, then native `input`/`change` on the bound it moved. `dates.compareDates` stays public for the clamp against the opposite bound.
82
+ - The calendar keeps the seams this requires: `datefocus`, `highlightedRange`, `data-range-preview` (a proposal is never announced as a range start/end), `activateDate()`, and the stable day-cell markers `data-date` / `data-range-start` / `data-range-end`.
83
+ - Moving a bound is an availability-checked action, never a silent rewrite: a refused or vetoed activation leaves the range untouched.
84
+ - A tested **starting point** ships in `docs/CALENDAR-INTEGRATION.md` and is covered by `test/browser/app-owned-drag.spec.js`. It implements the travel threshold, supersedes stale gestures, abandons a gesture on `pointercancel`/`lostpointercapture`, and refuses to apply after a concurrent `picker.range` change. It is not a feature-equivalent replacement: touch handling, a cursor affordance, the discard of a draft typed in the other bound, and a mid-flight endpoint switch stay application-owned.
85
+ - The existence of that recipe is not a reason to re-add an integrated drag. A separate optional `range-drag` module is not introduced before a real consumer demonstrates the need.
86
+
87
+ Dragging is not replaced by an opt-in attribute. Either the application implements it, or the picker does not offer it.
88
+
89
+ ## D10 — Range preview stays picker-owned
90
+
91
+ The 0.6 scope review considered removing the hover/keyboard range preview together with endpoint dragging. Measured on the post-drag code, it does not meet the removal criteria:
92
+
93
+ - one transient field (`_previewDate`), ~60 lines in the picker, a 20-line pure projection (`previewRange()`), three listeners and two CSS rules;
94
+ - no lifecycle guard beyond `_open` and the current range;
95
+ - synchronous (no `await`), so the transition harness covers it without a new async case.
96
+
97
+ It also carries a real UX contract: the band shows what the next pick will commit, and a proposal is never announced as a committed selection (`data-range-preview`). Removing it would either lose that contract or force every application to reimplement it.
98
+
99
+ The drag failed these criteria: pointer capture, cancellation, trailing click, endpoint identity and a cursor affordance were its own machine. The two features are therefore not a pair — drag is application-owned (D9), preview stays here.
100
+
101
+ Revisit only if a real consumer needs a picker without preview. The answer would then be an explicit opt-out, not a silent removal.
package/docs/TESTING.md CHANGED
@@ -48,6 +48,14 @@ Browser tests should cover behavior that cannot be trusted to a DOM shim:
48
48
  - range validation;
49
49
  - forced-colors/zoom smoke cases.
50
50
 
51
+ ## Transition harness
52
+
53
+ `test/browser/lifecycle.spec.js` is the controlled-promise matrix: an operation suspends on a source promise, a newer state change lands, then the old operation settles. Each case asserts canonical value, visible text, hidden ISO and events (plus validity for constraint cases). Extend this matrix instead of adding another one-off regression spec for a lifecycle guard.
54
+
55
+ ## Evidence bar
56
+
57
+ A test for a defect fails before the fix. A finding states the violated invariant, a reproducible scenario reachable through supported usage, the concrete impact, and the failure mode. Unsupported structural mutations are closed by "disconnect and reconnect" (`ARCHITECTURE.md` §11.3). An audit or review that finds no actionable defect is a valid outcome.
58
+
51
59
  ## Core invariants
52
60
 
53
61
  Keep explicit tests for these regressions:
package/docs/USE_CASES.md CHANGED
@@ -113,7 +113,6 @@ Acceptance:
113
113
  - a stale async availability response arriving after the active endpoint changed never commits a value or closes the popup;
114
114
  - calendar interaction never produces an inverted range: while a range is being created, a second selection before the first is sorted (`10` then `5` commits `5 -> 10`) in one atomic update; an explicit change to one bound of an already complete pair keeps that bound's identity instead;
115
115
  - pointer hover and keyboard navigation project the range the next selection would commit onto the band, without changing any value, and a selection commits exactly what was projected;
116
- - the endpoints of a complete range can be dragged to adjust it, through the same projection, the same availability check and the same cancelable activation as a click;
117
116
  - only the bounds are validated; per-night availability stays application-owned.
118
117
 
119
118
  ## U10 — Date with native time companions
@@ -149,7 +148,7 @@ Do not grow the API to cover these without a separate design:
149
148
  - timezone conversion;
150
149
  - appointment recurrence;
151
150
  - resource scheduling;
152
- - drag/drop;
151
+ - drag/drop of events or range endpoints (endpoint moves are application-owned, see [DECISIONS D9](DECISIONS.md));
153
152
  - event rendering;
154
153
  - multi-day scheduler views.
155
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lekoala/date-picker",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Native-first date picker and inline calendar primitives",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -141,7 +141,7 @@
141
141
  "lint": "biome check .",
142
142
  "format": "biome format --write .",
143
143
  "typecheck": "tsc -p jsconfig.json",
144
- "types": "tsc -p tsconfig.types.json",
144
+ "types": "tsc -p tsconfig.types.json && bun scripts/trim-types.js",
145
145
  "test": "bun test test/unit",
146
146
  "test:syntax": "bun scripts/check-syntax.js",
147
147
  "test:types": "tsc -p test/types/tsconfig.json",
@@ -31,6 +31,7 @@ import {
31
31
  weekdayNames,
32
32
  } from "./intl.js";
33
33
  import { getDefaultMessages, setDefaultMessages, subscribeDefaultMessages } from "./messages.js";
34
+ import { createSession, guard } from "./session.js";
34
35
  import { normalizeDateStates } from "./source.js";
35
36
  import { upgradeProperties } from "./upgrade-properties.js";
36
37
 
@@ -163,7 +164,14 @@ export class DateCalendarElement extends HTMLElement {
163
164
  this._id = `date-calendar-${++uid}`;
164
165
  this._model = new CalendarModel();
165
166
  this._connected = false;
167
+ /** Connection lifetime token. Deferred work captures it, never reads it late. */
168
+ /** @type {AbortController | null} */
169
+ this._session = null;
166
170
  this._rendering = false;
171
+ /** Persistent live heading; render() updates its text after it is attached. @type {HTMLHeadingElement | null} */
172
+ this._heading = null;
173
+ /** Replaceable visual subtree kept separate from the persistent live heading. @type {HTMLDivElement | null} */
174
+ this._renderRoot = null;
167
175
  /** @type {DateSource | null} */
168
176
  this._source = null;
169
177
  /** Intent the host attached to the next activation of `date`.
@@ -207,6 +215,7 @@ export class DateCalendarElement extends HTMLElement {
207
215
 
208
216
  connectedCallback() {
209
217
  if (this._connected) return;
218
+ this._session = createSession();
210
219
  upgradeProperties(this, [
211
220
  "locale",
212
221
  "min",
@@ -226,7 +235,12 @@ export class DateCalendarElement extends HTMLElement {
226
235
  "value",
227
236
  "display",
228
237
  ]);
238
+ this._normalizeAttributes();
229
239
  this._syncModel();
240
+ // An authored display may be non-canonical or outside the renderable
241
+ // window: reflect the clamped model month rather than keeping author text
242
+ // that the model never accepted.
243
+ if (this.hasAttribute("display")) this._reflect("display", this._model.display);
230
244
  upgradeProperties(this, ["focusedDate"]);
231
245
  this._stopDefaultControlClasses = subscribeDefaultControlClasses(() => {
232
246
  if (usesDefaultControlClasses(this._controlClasses)) this.render();
@@ -235,10 +249,11 @@ export class DateCalendarElement extends HTMLElement {
235
249
  if (this._messages === null) this.render();
236
250
  });
237
251
  this._connected = true;
238
- this.addEventListener("click", this._onClick);
239
- this.addEventListener("change", this._onChange);
240
- this.addEventListener("keydown", this._onKeyDown);
241
- this.addEventListener("focusin", this._onFocusIn);
252
+ const { signal } = this._session;
253
+ this.addEventListener("click", this._onClick, { signal });
254
+ this.addEventListener("change", this._onChange, { signal });
255
+ this.addEventListener("keydown", this._onKeyDown, { signal });
256
+ this.addEventListener("focusin", this._onFocusIn, { signal });
242
257
  this.render();
243
258
  }
244
259
 
@@ -252,18 +267,58 @@ export class DateCalendarElement extends HTMLElement {
252
267
  this._loadController = null;
253
268
  this._loadingKey = "";
254
269
  this._loadingPromise = null;
255
- this.removeEventListener("click", this._onClick);
256
- this.removeEventListener("change", this._onChange);
257
- this.removeEventListener("keydown", this._onKeyDown);
258
- this.removeEventListener("focusin", this._onFocusIn);
270
+ this.removeAttribute("data-loading");
271
+ this._session?.abort();
272
+ this._session = null;
273
+ }
274
+
275
+ /** Establish canonical connect-time constraints. */
276
+ _normalizeAttributes() {
277
+ const value = this.getAttribute("value");
278
+ if (value && !isDate(value)) this.removeAttribute("value");
279
+ for (const name of ["min", "max"]) {
280
+ const raw = this.getAttribute(name);
281
+ if (raw && !isDate(raw)) this.removeAttribute(name);
282
+ }
259
283
  }
260
284
 
261
- attributeChangedCallback() {
262
- if (!this._connected || this._rendering) return;
285
+ /** @param {string} name @param {string|null} _oldValue @param {string|null} newValue */
286
+ attributeChangedCallback(name, _oldValue, newValue) {
287
+ if (this._rendering) return;
288
+ // Attributes are prepared offline and normalized at connect: no reflection,
289
+ // model sync or render runs on a detached element.
290
+ if (!this._connected) return;
291
+ if (name === "value" && newValue && !isDate(newValue)) {
292
+ this._reflect("value", this._model.value);
293
+ return;
294
+ }
295
+ if ((name === "min" || name === "max") && newValue && !isDate(newValue)) {
296
+ this._reflect(name, "");
297
+ if (this._connected) {
298
+ this._syncModel();
299
+ this.render();
300
+ }
301
+ return;
302
+ }
303
+ if (name === "display" && newValue) {
304
+ try {
305
+ if (monthKey(newValue) !== newValue) throw new TypeError("Non-canonical month");
306
+ } catch {
307
+ this._reflect("display", this._model.display);
308
+ return;
309
+ }
310
+ }
263
311
  this._syncModel();
264
312
  this.render();
265
313
  }
266
314
 
315
+ /** Run a focus microtask only in the connection that scheduled it. @param {() => void} callback */
316
+ _queueFocus(callback) {
317
+ const session = this._session;
318
+ if (!session) return;
319
+ queueMicrotask(guard(session.signal, callback));
320
+ }
321
+
267
322
  /** @public */
268
323
  get locale() {
269
324
  return resolveLocale(this.getAttribute("locale") || "");
@@ -607,13 +662,14 @@ export class DateCalendarElement extends HTMLElement {
607
662
  this.dispatchEvent(new CustomEvent("dateloaderror", { detail: { ...range, error }, bubbles: true }));
608
663
  return "failed";
609
664
  } finally {
665
+ // All cleanup belongs to the controller that started this load: a
666
+ // stale operation must never clear the key or promise of a newer load
667
+ // for the same month (a reconnect can start one immediately).
610
668
  if (this._loadController === controller) {
611
669
  this._loadController = null;
612
- this.removeAttribute("data-loading");
613
- }
614
- if (this._loadingKey === key) {
615
670
  this._loadingKey = "";
616
671
  this._loadingPromise = null;
672
+ this.removeAttribute("data-loading");
617
673
  }
618
674
  }
619
675
  })();
@@ -669,7 +725,7 @@ export class DateCalendarElement extends HTMLElement {
669
725
  // Same month: no full render, just move the roving tabindex.
670
726
  this._moveFocusDom(previousFocused, this._model.focused, moveFocus);
671
727
  }
672
- if (moveFocus) queueMicrotask(() => this.focusGrid());
728
+ if (moveFocus) this._queueFocus(() => this.focusGrid());
673
729
  }
674
730
 
675
731
  /** @param {string} previous @param {string} next @param {boolean} [moveFocus] */
@@ -781,6 +837,8 @@ export class DateCalendarElement extends HTMLElement {
781
837
  * @param {string} date @returns {Promise<boolean>}
782
838
  */
783
839
  async activateDate(date) {
840
+ if (!this._connected) return false;
841
+ const session = this._session;
784
842
  const hostIntent = this._hostIntent?.date === date ? this._hostIntent.intent : undefined;
785
843
  this._hostIntent = null;
786
844
  /** @param {Event} event */
@@ -792,6 +850,7 @@ export class DateCalendarElement extends HTMLElement {
792
850
  // source resolves must not be treated as available, and a cancelled or
793
851
  // failed load must not authorize it either.
794
852
  const confirmed = await this.ensureDate(date);
853
+ if (!session || session.signal.aborted) return false;
795
854
  const state = this.getDateState(date);
796
855
  if (!confirmed || state.disabled) {
797
856
  this.dispatchEvent(tagged(new CustomEvent("dateinvalid", { detail: { date, state }, bubbles: true })));
@@ -806,7 +865,7 @@ export class DateCalendarElement extends HTMLElement {
806
865
  }),
807
866
  ),
808
867
  );
809
- if (!accepted) return false;
868
+ if (!accepted || session.signal.aborted) return false;
810
869
  if (this.selection === "single") {
811
870
  const previousValue = this._model.value;
812
871
  this._model.setValue(date);
@@ -1007,8 +1066,21 @@ export class DateCalendarElement extends HTMLElement {
1007
1066
  const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this.messages.year)}</label>
1008
1067
  <input id="${this._id}-year" class="dp-year-input ${yearClass}" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this.messages.year)}">`;
1009
1068
 
1069
+ if (!this._heading?.isConnected || !this._renderRoot?.isConnected) {
1070
+ this.replaceChildren();
1071
+ const heading = document.createElement("h2");
1072
+ heading.id = headingId;
1073
+ heading.className = "dp-calendar-heading dp-visually-hidden";
1074
+ heading.setAttribute("aria-live", "polite");
1075
+ const renderRoot = document.createElement("div");
1076
+ renderRoot.className = "dp-calendar-render";
1077
+ this.append(heading, renderRoot);
1078
+ this._heading = heading;
1079
+ this._renderRoot = renderRoot;
1080
+ }
1081
+
1010
1082
  this._rendering = true;
1011
- this.innerHTML = `
1083
+ this._renderRoot.innerHTML = `
1012
1084
  <div class="dp-calendar-shell">
1013
1085
  <div class="dp-calendar-header">
1014
1086
  ${yearFirst ? yearControl : monthControl}
@@ -1016,18 +1088,18 @@ export class DateCalendarElement extends HTMLElement {
1016
1088
  <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
1089
  <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>
1018
1090
  </div>
1019
- <h2 id="${headingId}" class="dp-calendar-heading dp-visually-hidden" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
1020
1091
  <table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
1021
1092
  <thead><tr>${weekHeader}${dayHeaders}</tr></thead>
1022
1093
  <tbody>${rows}</tbody>
1023
1094
  </table>
1024
1095
  </div>`;
1096
+ this._heading.textContent = formatMonthYear(`${display}-15`, locale);
1025
1097
  this._rendering = false;
1026
1098
  this._paintedRange = { ...this._highlightedRange };
1027
1099
  this._paintedPreview = this.hasAttribute("data-range-preview");
1028
1100
 
1029
1101
  if (focusKey) {
1030
- queueMicrotask(() => {
1102
+ this._queueFocus(() => {
1031
1103
  let selector = focusKey;
1032
1104
  // A day-cell key goes stale across display changes: the old date can
1033
1105
  // reappear as outside-month padding in the new month (fixed-weeks or
package/src/date-field.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isDate } from "./date.js";
2
2
  import { createDateAdapter } from "./intl.js";
3
+ import { createSession } from "./session.js";
3
4
 
4
5
  /**
5
6
  * Whether the user may edit this input right now. `:disabled` is the effective
@@ -35,6 +36,8 @@ export class DateFieldController {
35
36
  this.canonical = "";
36
37
  this._dirty = false;
37
38
  this._commitId = 0;
39
+ /** Lifetime token: torn down with the field, so no commit can start or resume. */
40
+ this._session = createSession();
38
41
  this._originalName = input.getAttribute("name") || "";
39
42
  /** @type {MutationObserver | null} */
40
43
  this._attributeObserver = null;
@@ -179,34 +182,50 @@ export class DateFieldController {
179
182
  * `options` is forwarded to the `confirm` gate so a revalidation can ask it
180
183
  * not to move the calendar display (source reloads must not re-home it).
181
184
  * @param {{moveDisplay?: boolean}} [options]
182
- * @returns {Promise<{status: "ok" | "clear" | "invalid" | "stale", value?: string}>}
185
+ * @returns {Promise<{status: "ok" | "clear" | "invalid" | "stale", value?: string, commitId: number}>}
183
186
  */
184
187
  async commit(options = {}) {
185
188
  const input = this.input;
186
189
  const commitId = ++this._commitId;
190
+ // One controller has one lifetime, so this captured session is the
191
+ // controller's only session: teardown aborts it for good.
192
+ const session = this._session;
193
+ if (session.signal.aborted) return { status: "stale", commitId };
187
194
  const text = input.value.trim();
188
195
  if (!text) {
189
- if (commitId !== this._commitId) return { status: "stale" };
196
+ if (commitId !== this._commitId) return { status: "stale", commitId };
190
197
  input.setCustomValidity("");
191
- return { status: "clear", value: "" };
198
+ return { status: "clear", value: "", commitId };
192
199
  }
193
200
  const parsed = this.adapter.parse(text);
194
201
  if (!parsed) {
195
- if (commitId !== this._commitId) return { status: "stale" };
202
+ if (commitId !== this._commitId) return { status: "stale", commitId };
196
203
  if (this.hidden) this.hidden.value = "";
197
204
  input.setCustomValidity(this.messages.invalidDate);
198
- return { status: "invalid" };
205
+ return { status: "invalid", commitId };
199
206
  }
200
207
  if (this.confirm) {
201
208
  const result = await this.confirm(parsed, options);
202
- if (commitId !== this._commitId) return { status: "stale" };
209
+ // The session is captured by this controller's own lifetime: a teardown
210
+ // aborts it, so the pending result must not touch validity or the hidden
211
+ // field any more.
212
+ if (session.signal.aborted || commitId !== this._commitId) {
213
+ return { status: "stale", commitId };
214
+ }
203
215
  if (!result?.ok) {
204
216
  if (this.hidden) this.hidden.value = "";
205
217
  input.setCustomValidity(result?.message || this.messages.unavailableDate);
206
- return { status: "invalid" };
218
+ return { status: "invalid", commitId };
207
219
  }
208
220
  }
209
- return { status: "ok", value: parsed };
221
+ return { status: "ok", value: parsed, commitId };
222
+ }
223
+
224
+ /** A coordinator must re-check this after awaiting commit(), including for
225
+ * synchronous clear/invalid results whose Promise resumes in a microtask.
226
+ * @param {number} commitId */
227
+ isCommitCurrent(commitId) {
228
+ return commitId === this._commitId;
210
229
  }
211
230
 
212
231
  /**
@@ -234,8 +253,12 @@ export class DateFieldController {
234
253
  }
235
254
 
236
255
  /** Remove the hidden field and attribute observer, restoring the original
237
- * authored `name` on the visible input. */
256
+ * authored `name` on the visible input. A disconnect is a teardown, not a
257
+ * pause: in-flight commits are invalidated and later ones are refused, so a
258
+ * reconnected picker rebuilds from the live DOM instead of resuming them. */
238
259
  teardown() {
260
+ this.dirty();
261
+ this._session.abort();
239
262
  this._attributeObserver?.disconnect();
240
263
  this._attributeObserver = null;
241
264
  const hiddenName = this.hidden?.getAttribute("name") || this._originalName;
@@ -267,8 +267,8 @@ date-picker > input:not([type="hidden"]):focus-visible,
267
267
  }
268
268
 
269
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. */
270
+ step short of the committed band, so a hovered or keyboard preview is never
271
+ mistaken for the selection itself. */
272
272
  [data-range-preview] .dp-day:is([data-range-start="true"], [data-range-end="true"]) {
273
273
  background: color-mix(in srgb, var(--dp-accent) 58%, var(--dp-bg));
274
274
  }
@@ -277,17 +277,6 @@ date-picker > input:not([type="hidden"]):focus-visible,
277
277
  background: color-mix(in srgb, var(--dp-accent) 11%, var(--dp-bg));
278
278
  }
279
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
-
291
280
  .dp-day-number {
292
281
  display: block;
293
282
  font-variant-numeric: tabular-nums;