@lekoala/date-picker 0.5.3 → 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.
package/docs/API.md CHANGED
@@ -27,6 +27,16 @@ The public surface — flat event names, `selection` values, `--dp-*` styling to
27
27
 
28
28
  Presentation-only range band. It never changes `value`, `selection` or `focusedDate`. It accepts an ordered `{ start, end }` pair of `YYYY-MM-DD` values; `end` may be empty (range in progress), and a `start === end` range renders both endpoints with a dedicated label. `null` and `{ start: "", end: "" }` reset the band. Inverted ranges and `{ start: "", end: "…" }` throw a `TypeError` — the property never reorders or guesses. Styled spans render `data-range-start="true"`, `data-in-range="true"`, `data-range-end="true"` and extend each day's `aria-label` accordingly.
29
29
 
30
+ ### Day cell data attributes
31
+
32
+ Day cells stay component-owned DOM (D3): `.dp-*` classes are not a styling API and the cell cannot be replaced. These read-only data attributes are stable hooks for application-owned interactions:
33
+
34
+ - `data-date="YYYY-MM-DD"` — canonical date of the cell;
35
+ - `data-range-start` / `data-in-range` / `data-range-end` — band position painted from `highlightedRange` (a one-day range carries both start and end);
36
+ - `data-range-preview` — on the calendar, not the cell: the band is a proposal, so cells stop contributing a range start/end to their accessible name while it is set.
37
+
38
+ Applications may read them (for example to hit-test a pointer gesture, see [CALENDAR-INTEGRATION](CALENDAR-INTEGRATION.md)); they must not write them.
39
+
30
40
  ### Callback properties
31
41
 
32
42
  ```js
@@ -305,7 +315,7 @@ The business range may be temporarily incomplete or inverted; `calendar.highligh
305
315
  - **Calendar interaction never produces an inverted range.** While a range is being created, a second pick before the anchor is sorted rather than refused: `10` then `15` gives `10 -> 15`, `10` then `5` gives `5 -> 10`, and `10` then `10` a one-day range. That transition moves **both** bounds at once and is applied in a single write — one `rangechange`, with synthetic `input`/`change` on each bound that moved, and no intermediate inverted pair ever observable. Typed text keeps the opposite contract and may be temporarily inverted (see [DECISIONS D6](DECISIONS.md)).
306
316
  - Changing one bound of an already complete pair keeps that bound's identity: moving `end` before `start` is refused (`dateinvalid`, nothing changes) instead of rewriting the other field.
307
317
  - **Preview.** While a range is being created, hovering a day or moving the keyboard focus projects the range the next pick would commit onto `calendar.highlightedRange`, and the pick commits exactly what was projected. The projection is visual only: no `rangechange`, no field write, no validation and no source request. It reads the month already loaded, so a day known to be unavailable promises nothing; the real availability check still runs on commit. Pointer hover never moves `focusedDate`, which stays the keyboard target. Leaving the grid, Escape and closing the popup restore the committed band.
308
- - **Endpoint drag.** On a complete, ordered range whose two ends sit on different days, each endpoint can be dragged with a mouse or pen (pointer events plus capture, armed only once the pointer travels). A handle never crosses the other one — the candidate is clamped to it, equality allowed — an unavailable cell is not a drop target, and `pointercancel` restores the committed band. The drop runs the same projection and the same `ensureDate` + cancelable `dateactivate` path as a click, emits one `rangechange`, and keeps the popup open: a drag adjusts a range, it does not validate it. A press without travel stays a plain click. Touch keeps native scrolling and stays a plain tap.
318
+ - Adjusting an existing bound by pointer (dragging an endpoint) is application-owned: see [DECISIONS D9](DECISIONS.md) and the recipe in [CALENDAR-INTEGRATION.md](CALENDAR-INTEGRATION.md). The picker itself only moves a bound through a calendar activation (`activateDate()` / `dateactivate`).
309
319
  - Each bound owns its trigger (`input[data-range-start]` then its trigger, `input[data-range-end]` then its trigger); the two triggers share **one** popup, calendar, source and state. Pressing the other bound's trigger while the popup is open switches the active bound and keeps it open instead of toggling it shut. A trigger never opens a `readonly`/`disabled` bound.
310
320
  - Typing stays independent per field. Moving `start` past `end` keeps both values and flags the *modified* bound with `rangeOrderStart`; fixing either side revalidates both. Parse/source errors are never cleared by cross-bound revalidation.
311
321
  - A stale availability response resolving after the active endpoint changed (or after a newer selection) never commits; a field focus that switches the endpoint invalidates in-flight activations.
@@ -330,29 +340,6 @@ rangePosition("2026-09-12", range); // "start" | "in" | "end" | "single" | ""
330
340
 
331
341
  `normalizeRange` validates an ordered displayable range and never reorders: an inversion or an `end` without a `start` throws. `rangePosition` classifies one day within a displayable range (see [`highlightedRange`](#highlightedrange)).
332
342
 
333
- ## `DateRangeController`
334
-
335
- The pure two-bound state machine behind `date-picker[range]`. It owns the
336
- selection rules and knows nothing about DOM, popups or async availability.
337
-
338
- ```js
339
- const model = new DateRangeController();
340
- model.focus("start");
341
- model.activate("2026-09-10"); // { status: "pending", endpoint: "start", … }
342
- model.previewRange("2026-09-05"); // { start: "2026-09-05", end: "2026-09-10" }
343
- model.activate("2026-09-05"); // changedEndpoints: ["start", "end"]
344
- ```
345
-
346
- - `project(date, editable?)` / `projectEndpoint(date, endpoint, editable?)` describe a transition without applying it;
347
- - `activate(date, editable?)` / `moveEndpoint(date, endpoint, editable?)` apply one;
348
- - `previewRange(date, endpoint?, editable?)` returns the displayable band a candidate would produce, or `null` when there is nothing to promise.
349
-
350
- A transition is `{ status, endpoint, changedEndpoints, range, activeEndpoint, close? }`.
351
- `range` is the whole resulting pair, because one interaction can move both
352
- bounds; `changedEndpoints` names the ones that actually move. Every rule exists
353
- once, as a projection, and the previews read the same projections the commit
354
- applies.
355
-
356
343
  ## `createDateAdapter(locale)`
357
344
 
358
345
  Locale-aware editable numeric adapter:
@@ -197,3 +197,75 @@ Expected homes:
197
197
  - start/end relationship → `date-range.js`.
198
198
 
199
199
  Do not add a plugin system to solve a one-off rendering need.
200
+
201
+ ## 11. Lifecycle and supported mutations
202
+
203
+ ### 11.1 One session token
204
+
205
+ Every lifetime owns one `AbortController`: a picker connection, a calendar connection, a `DateFieldController`. Listeners are attached with its `signal`, and teardown aborts it.
206
+
207
+ Any write that follows a suspension (`await`, timer, microtask) is checked against **the session captured when the operation started**, never against the current one:
208
+
209
+ ```js
210
+ const session = this._session;
211
+ const result = await field.commit();
212
+ if (session.signal.aborted) return;
213
+ ```
214
+
215
+ A reconnected component has a new, live session. Testing `this._session` after the await would let a stale continuation through — that is the exact failure `1977785` fixed locally. This rule replaces per-path guards; it is not one more guard.
216
+
217
+ Scheduling stays at the call site (`queueMicrotask` vs `setTimeout`) because Chromium focus behavior depends on it. Only the guard is shared.
218
+
219
+ ### 11.2 Three distinct questions, three mechanisms
220
+
221
+ | Question | Mechanism | Scope |
222
+ |---|---|---|
223
+ | Is the session I started in still alive? | captured `AbortSignal` | per connection / controller |
224
+ | Is my operation still the newest intent? | monotonic generation (`commitId`, activation id) | per field / per activation |
225
+ | Is the action still allowed now? | native state + interaction state (`isEditable()`, `:disabled`, `_open`, active bound) | read at apply time |
226
+
227
+ Do not merge them. The signal says nothing about a newer intent, and neither says whether a `<fieldset>` was disabled or the popup closed.
228
+
229
+ ### 11.3 Supported mutations
230
+
231
+ | Element | Policy |
232
+ |---|---|
233
+ | single/range mode | read at connect; changing it requires reconnecting |
234
+ | identity of `[data-range-start]` / `[data-range-end]` inputs | fixed at connect |
235
+ | presence and role of `[data-time-start]` / `[data-time-end]` | fixed at connect |
236
+ | initial values (`input.value`, `defaultValue`) | read at connect |
237
+ | public API (`value`, `range`, `min`, `max`, `locale`, `source`, `dateState`, `renderDay`, `isDateDisabled`, `messages`, `controlClasses`, `monthFormat`, `openOnFocus`, `coordinateSpace`) | live; each setter documents whether it invalidates pending work |
238
+ | `name`, `form`, `disabled`, `readonly`, disabled fieldset, form reset, `defaultValue` | followed: this is the browser contract, not an arbitrary mutation |
239
+ | visible input text | draft, read at commit (blur/change/validate) |
240
+ | replacing child inputs | unsupported without reconnecting |
241
+
242
+ Undocumented structural mutations carry no guarantee:
243
+
244
+ > Undocumented structural mutations provide no resynchronization guarantee and no guarantee that interaction state is preserved. The component supports only the structure captured at connect time and the mutations listed above. To change the structure, disconnect and reconnect the component.
245
+
246
+ A security or native-data-integrity defect is still acceptable **inside a supported usage**, with a reproducible scenario. It is not required to be robust against arbitrary combinations of DOM mutations.
247
+
248
+ The initial `name` is captured for restoration; the current `name` stays dynamic.
249
+
250
+ ### 11.4 Reconnect is a new session
251
+
252
+ Moving an element in the DOM disconnects and reconnects it. That rebuilds the controllers and calendar and abandons in-flight interaction without emitting an event. Native values and public configuration survive; popup, focus and active-bound state do not. This is the contract, not an edge case to patch.
253
+
254
+ ### 11.5 State authorities
255
+
256
+ Name the authority before touching sync code; everything else is a projection recomputed from it.
257
+
258
+ | Mode | Authority | Projections |
259
+ |---|---|---|
260
+ | single | `DatePickerElement._value` | field canonical, hidden ISO, formatted text, `calendar.value`, `value` attribute |
261
+ | range | `DateRangeController.start/end` | each field canonical, hidden ISO, texts, `calendar.highlightedRange` (only when displayable) |
262
+ | calendar | `CalendarModel` | DOM, `_renderedStates` (paint cache) |
263
+ | draft | input text | re-read at commit; `_dirty` says whether it differs from the authority |
264
+ | native default | `input.defaultValue` | restored by reset; `_generatedDefault` separates a generated default from an application one |
265
+
266
+ Priority rules:
267
+
268
+ - assigning `value`/`range` invalidates pending activations and commits;
269
+ - a form reset wins and resynchronizes everything;
270
+ - a direct native write to the input is read at commit, not watched;
271
+ - a commit result is applied only if the captured session is alive **and** its `commitId` is current.
@@ -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.3",
3
+ "version": "0.6.0",
4
4
  "description": "Native-first date picker and inline calendar primitives",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -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,8 +164,9 @@ export class DateCalendarElement extends HTMLElement {
163
164
  this._id = `date-calendar-${++uid}`;
164
165
  this._model = new CalendarModel();
165
166
  this._connected = false;
166
- /** Connect/disconnect generation guarding deferred work. */
167
- this._lifecycleId = 0;
167
+ /** Connection lifetime token. Deferred work captures it, never reads it late. */
168
+ /** @type {AbortController | null} */
169
+ this._session = null;
168
170
  this._rendering = false;
169
171
  /** Persistent live heading; render() updates its text after it is attached. @type {HTMLHeadingElement | null} */
170
172
  this._heading = null;
@@ -213,7 +215,7 @@ export class DateCalendarElement extends HTMLElement {
213
215
 
214
216
  connectedCallback() {
215
217
  if (this._connected) return;
216
- this._lifecycleId++;
218
+ this._session = createSession();
217
219
  upgradeProperties(this, [
218
220
  "locale",
219
221
  "min",
@@ -233,7 +235,12 @@ export class DateCalendarElement extends HTMLElement {
233
235
  "value",
234
236
  "display",
235
237
  ]);
238
+ this._normalizeAttributes();
236
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);
237
244
  upgradeProperties(this, ["focusedDate"]);
238
245
  this._stopDefaultControlClasses = subscribeDefaultControlClasses(() => {
239
246
  if (usesDefaultControlClasses(this._controlClasses)) this.render();
@@ -242,10 +249,11 @@ export class DateCalendarElement extends HTMLElement {
242
249
  if (this._messages === null) this.render();
243
250
  });
244
251
  this._connected = true;
245
- this.addEventListener("click", this._onClick);
246
- this.addEventListener("change", this._onChange);
247
- this.addEventListener("keydown", this._onKeyDown);
248
- 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 });
249
257
  this.render();
250
258
  }
251
259
 
@@ -255,20 +263,31 @@ export class DateCalendarElement extends HTMLElement {
255
263
  this._stopDefaultMessages?.();
256
264
  this._stopDefaultMessages = null;
257
265
  this._connected = false;
258
- this._lifecycleId++;
259
266
  this._loadController?.abort();
260
267
  this._loadController = null;
261
268
  this._loadingKey = "";
262
269
  this._loadingPromise = null;
263
- this.removeEventListener("click", this._onClick);
264
- this.removeEventListener("change", this._onChange);
265
- this.removeEventListener("keydown", this._onKeyDown);
266
- 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
+ }
267
283
  }
268
284
 
269
285
  /** @param {string} name @param {string|null} _oldValue @param {string|null} newValue */
270
286
  attributeChangedCallback(name, _oldValue, newValue) {
271
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;
272
291
  if (name === "value" && newValue && !isDate(newValue)) {
273
292
  this._reflect("value", this._model.value);
274
293
  return;
@@ -289,18 +308,15 @@ export class DateCalendarElement extends HTMLElement {
289
308
  return;
290
309
  }
291
310
  }
292
- if (!this._connected) return;
293
311
  this._syncModel();
294
312
  this.render();
295
313
  }
296
314
 
297
- /** Run a focus microtask only in the lifecycle that scheduled it. @param {() => void} callback */
315
+ /** Run a focus microtask only in the connection that scheduled it. @param {() => void} callback */
298
316
  _queueFocus(callback) {
299
- const lifecycleId = this._lifecycleId;
300
- queueMicrotask(() => {
301
- if (!this._connected || lifecycleId !== this._lifecycleId) return;
302
- callback();
303
- });
317
+ const session = this._session;
318
+ if (!session) return;
319
+ queueMicrotask(guard(session.signal, callback));
304
320
  }
305
321
 
306
322
  /** @public */
@@ -646,13 +662,14 @@ export class DateCalendarElement extends HTMLElement {
646
662
  this.dispatchEvent(new CustomEvent("dateloaderror", { detail: { ...range, error }, bubbles: true }));
647
663
  return "failed";
648
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).
649
668
  if (this._loadController === controller) {
650
669
  this._loadController = null;
651
- this.removeAttribute("data-loading");
652
- }
653
- if (this._loadingKey === key) {
654
670
  this._loadingKey = "";
655
671
  this._loadingPromise = null;
672
+ this.removeAttribute("data-loading");
656
673
  }
657
674
  }
658
675
  })();
@@ -821,7 +838,7 @@ export class DateCalendarElement extends HTMLElement {
821
838
  */
822
839
  async activateDate(date) {
823
840
  if (!this._connected) return false;
824
- const lifecycleId = this._lifecycleId;
841
+ const session = this._session;
825
842
  const hostIntent = this._hostIntent?.date === date ? this._hostIntent.intent : undefined;
826
843
  this._hostIntent = null;
827
844
  /** @param {Event} event */
@@ -833,7 +850,7 @@ export class DateCalendarElement extends HTMLElement {
833
850
  // source resolves must not be treated as available, and a cancelled or
834
851
  // failed load must not authorize it either.
835
852
  const confirmed = await this.ensureDate(date);
836
- if (!this._connected || lifecycleId !== this._lifecycleId) return false;
853
+ if (!session || session.signal.aborted) return false;
837
854
  const state = this.getDateState(date);
838
855
  if (!confirmed || state.disabled) {
839
856
  this.dispatchEvent(tagged(new CustomEvent("dateinvalid", { detail: { date, state }, bubbles: true })));
@@ -848,7 +865,7 @@ export class DateCalendarElement extends HTMLElement {
848
865
  }),
849
866
  ),
850
867
  );
851
- if (!accepted) return false;
868
+ if (!accepted || session.signal.aborted) return false;
852
869
  if (this.selection === "single") {
853
870
  const previousValue = this._model.value;
854
871
  this._model.setValue(date);
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,8 +36,8 @@ export class DateFieldController {
35
36
  this.canonical = "";
36
37
  this._dirty = false;
37
38
  this._commitId = 0;
38
- /** A torn-down controller is terminal: no commit may start or resume. */
39
- this._disposed = false;
39
+ /** Lifetime token: torn down with the field, so no commit can start or resume. */
40
+ this._session = createSession();
40
41
  this._originalName = input.getAttribute("name") || "";
41
42
  /** @type {MutationObserver | null} */
42
43
  this._attributeObserver = null;
@@ -186,7 +187,10 @@ export class DateFieldController {
186
187
  async commit(options = {}) {
187
188
  const input = this.input;
188
189
  const commitId = ++this._commitId;
189
- if (this._disposed) return { status: "stale", 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 };
190
194
  const text = input.value.trim();
191
195
  if (!text) {
192
196
  if (commitId !== this._commitId) return { status: "stale", commitId };
@@ -202,9 +206,12 @@ export class DateFieldController {
202
206
  }
203
207
  if (this.confirm) {
204
208
  const result = await this.confirm(parsed, options);
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 };
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
+ }
208
215
  if (!result?.ok) {
209
216
  if (this.hidden) this.hidden.value = "";
210
217
  input.setCustomValidity(result?.message || this.messages.unavailableDate);
@@ -251,7 +258,7 @@ export class DateFieldController {
251
258
  * reconnected picker rebuilds from the live DOM instead of resuming them. */
252
259
  teardown() {
253
260
  this.dirty();
254
- this._disposed = true;
261
+ this._session.abort();
255
262
  this._attributeObserver?.disconnect();
256
263
  this._attributeObserver = null;
257
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;