@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/dist/date-picker.css +2 -13
- package/dist/date-picker.js +122 -257
- package/dist/date-picker.min.css +1 -1
- package/dist/date-picker.min.js +12 -12
- package/dist/date-picker.standalone.min.js +12 -12
- package/dist/types/date-range.d.ts +10 -33
- package/dist/types/date-range.d.ts.map +1 -1
- package/dist/types/session.d.ts +17 -0
- package/dist/types/session.d.ts.map +1 -0
- package/docs/ACCESSIBILITY.md +1 -1
- package/docs/API.md +11 -24
- package/docs/ARCHITECTURE.md +72 -0
- package/docs/CALENDAR-INTEGRATION.md +97 -0
- package/docs/DECISIONS.md +30 -3
- package/docs/TESTING.md +8 -0
- package/docs/USE_CASES.md +1 -2
- package/package.json +1 -1
- package/src/date-calendar.js +42 -25
- package/src/date-field.js +14 -7
- package/src/date-picker.css +2 -13
- package/src/date-picker.js +84 -236
- package/src/date-range.js +12 -54
- package/src/session.js +23 -0
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
|
-
-
|
|
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:
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
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
|
|
62
|
-
- Click
|
|
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
package/src/date-calendar.js
CHANGED
|
@@ -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
|
-
/**
|
|
167
|
-
|
|
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.
|
|
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
|
-
|
|
246
|
-
this.addEventListener("
|
|
247
|
-
this.addEventListener("
|
|
248
|
-
this.addEventListener("
|
|
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.
|
|
264
|
-
this.
|
|
265
|
-
this.
|
|
266
|
-
|
|
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
|
|
315
|
+
/** Run a focus microtask only in the connection that scheduled it. @param {() => void} callback */
|
|
298
316
|
_queueFocus(callback) {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
/**
|
|
39
|
-
this.
|
|
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
|
-
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
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.
|
|
261
|
+
this._session.abort();
|
|
255
262
|
this._attributeObserver?.disconnect();
|
|
256
263
|
this._attributeObserver = null;
|
|
257
264
|
const hiddenName = this.hidden?.getAttribute("name") || this._originalName;
|
package/src/date-picker.css
CHANGED
|
@@ -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
|
|
271
|
-
|
|
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;
|