@lekoala/date-picker 0.4.0 → 0.5.1

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.
Files changed (39) hide show
  1. package/README.md +294 -265
  2. package/custom-elements.json +50 -0
  3. package/dist/date-picker.css +20 -15
  4. package/dist/date-picker.js +366 -149
  5. package/dist/date-picker.min.css +1 -1
  6. package/dist/date-picker.min.js +13 -13
  7. package/dist/date-picker.standalone.min.js +34 -29
  8. package/dist/types/control-classes.d.ts +40 -0
  9. package/dist/types/control-classes.d.ts.map +1 -0
  10. package/dist/types/date-calendar.d.ts +35 -43
  11. package/dist/types/date-calendar.d.ts.map +1 -1
  12. package/dist/types/date-picker.d.ts +44 -53
  13. package/dist/types/date-picker.d.ts.map +1 -1
  14. package/dist/types/date.d.ts +4 -2
  15. package/dist/types/date.d.ts.map +1 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/index.d.ts.map +1 -1
  18. package/dist/types/intl.d.ts.map +1 -1
  19. package/dist/types/messages.d.ts +2 -0
  20. package/dist/types/messages.d.ts.map +1 -1
  21. package/dist/types/source.d.ts +1 -0
  22. package/dist/types/source.d.ts.map +1 -1
  23. package/dist/types/upgrade-properties.d.ts +8 -0
  24. package/dist/types/upgrade-properties.d.ts.map +1 -0
  25. package/docs/API.md +442 -408
  26. package/docs/DECISIONS.md +74 -62
  27. package/docs/USE_CASES.md +156 -144
  28. package/package.json +3 -3
  29. package/src/control-classes.js +80 -0
  30. package/src/date-calendar.js +124 -27
  31. package/src/date-field.js +1 -1
  32. package/src/date-picker.css +20 -15
  33. package/src/date-picker.js +133 -74
  34. package/src/date.js +8 -3
  35. package/src/index.js +1 -0
  36. package/src/intl.js +39 -10
  37. package/src/messages.js +12 -0
  38. package/src/source.js +14 -4
  39. package/src/upgrade-properties.js +14 -0
package/docs/API.md CHANGED
@@ -1,408 +1,442 @@
1
- # API
2
-
3
- The public surface — flat event names, `selection` values, `--dp-*` styling tokens and the `dateactivate`/`renderDay()` seams — is frozen in [DECISIONS.md](DECISIONS.md). Everything else stays internal and is free to change within the same major version.
4
-
5
- ## `<date-calendar>`
6
-
7
- ### Attributes / properties
8
-
9
- | Name | Type | Default | Meaning |
10
- |---------------------|------------------|--------------------|----------------------------------------------|
11
- | `value` | `YYYY-MM-DD` | `""` | Selected date. |
12
- | `display` | `YYYY-MM` | value/today month | Month being rendered. |
13
- | `min` | `YYYY-MM-DD` | `""` | Earliest activatable date. |
14
- | `max` | `YYYY-MM-DD` | `""` | Latest activatable date. |
15
- | `first-day` | `0..6` (`7` alias) | `1` | First weekday; Sunday=0, Monday=1. `7` is accepted as an ISO-style alias of Sunday (`@lekoala/calendar` uses 1..7) and never shifts the grid. Other values fall back to Monday. |
16
- | `locale` | BCP 47 string | document/navigator | `Intl` locale. |
17
- | `month-format` | `long \| short` | `long` | Visible month-name style in the month select; anything else falls back to `long`. The accessible grid heading always keeps the long month/year form. |
18
- | `selection` | `single \| none` | `single` | Whether accepted activation updates `value`. |
19
- | `fixed-weeks` | boolean | false | Render six rows; date math itself stays 4–6. |
20
- | `show-week-numbers` | boolean | false | Show ISO week numbers. The column renders only when `first-day === 1` (Monday-first): the ISO week and the displayed week only coincide then. With Sunday-first (`0`/`7`) the column is silently withheld. |
21
-
22
- `focusedDate` and `highlightedRange` are properties rather than reflected attributes.
23
-
24
- ### `highlightedRange`
25
-
26
- 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.
27
-
28
- ### Callback properties
29
-
30
- ```js
31
- calendar.isDateDisabled = (date) => boolean;
32
- calendar.dateState = (date, sourceState) => ({
33
- disabled,
34
- enabled, // true = lifts isDateDisabled() only; never min/max or resolved disabled
35
- description,
36
- ...applicationMetadata
37
- });
38
- calendar.renderDay = (date, state) => Node | string | null;
39
- ```
40
-
41
- `renderDay` decorates the day cell; it cannot replace the interactive cell. The returned node is placed inside a component-owned, non-interactive container that is `aria-hidden`. That container has no stable class name: style the nodes you return, not the component's internals (see [DECISIONS.md](DECISIONS.md#d3--css--dp--is-public--dp--is-not)).
42
-
43
- ### Source
44
-
45
- ```js
46
- calendar.source = async ({ start, end }, { signal }) => payload;
47
- ```
48
-
49
- or:
50
-
51
- ```js
52
- calendar.source = {
53
- load({ start, end }, { signal }) { ... }
54
- };
55
- ```
56
-
57
- ### Methods
58
-
59
- ```js
60
- calendar.previousMonth();
61
- calendar.nextMonth();
62
- calendar.focusDate("2026-09-10");
63
- calendar.focusGrid();
64
- calendar.getDateState("2026-09-10");
65
- await calendar.activateDate("2026-09-10");
66
- await calendar.ensureDate("2026-09-10");
67
- await calendar.refreshSource();
68
- calendar.render();
69
- ```
70
-
71
- ### Events
72
-
73
- #### `dateactivate`
74
-
75
- Cancelable. Fired for an allowed date after explicit click / Enter / Space.
76
-
77
- ```js
78
- calendar.addEventListener("dateactivate", (event) => {
79
- console.log(event.detail.date, event.detail.state);
80
- });
81
- ```
82
-
83
- Cancel it to refuse the activation before selection:
84
-
85
- ```js
86
- calendar.addEventListener("dateactivate", (event) => {
87
- if (applicationRule(event.detail.date)) event.preventDefault();
88
- });
89
- ```
90
-
91
- `activateDate(date)` runs that same path on demand: availability check, then
92
- the cancelable event, then selection. Click, keyboard and the range endpoint
93
- drag all go through it, so an application that cancels `dateactivate` cannot be
94
- bypassed by a different input device.
95
-
96
- #### `datefocus`
97
-
98
- Fired when a date cell actually receives focus, with `{ date, state }`. Arrow,
99
- Home, End, PageUp and PageDown all emit one through the cell that took focus.
100
-
101
- ```js
102
- calendar.addEventListener("datefocus", (event) => {
103
- console.log(event.detail.date);
104
- });
105
- ```
106
-
107
- It observes navigation and nothing else: a disabled day still reports it (grid
108
- navigation stays discoverable), hover never does, and assigning `focusedDate`
109
- without moving DOM focus stays silent. The three verbs are distinct —
110
- `datefocus` observes, `dateactivate` requests an activation, `datechange`
111
- observes a selection.
112
-
113
- #### `datechange`
114
-
115
- Fired when `selection="single"` changes `value`.
116
-
117
- #### `dateinvalid`
118
-
119
- Fired when the user tries to activate a disabled/out-of-range date.
120
-
121
- #### `displaychange`
122
-
123
- ```js
124
- {
125
- display: "2026-10",
126
- start: "2026-09-28",
127
- end: "2026-11-08"
128
- }
129
- ```
130
-
131
- The range reflects rendered cells (and therefore includes six-row padding when enabled).
132
-
133
- #### Source lifecycle
134
-
135
- - `dateloadstart`
136
- - `dateloadend`
137
- - `dateloaderror`
138
-
139
- ## `<date-picker>`
140
-
141
- Markup:
142
-
143
- ```html
144
- <label for="arrival">Arrival</label>
145
- <date-picker value="2026-09-10" locale="en-GB">
146
- <input id="arrival" name="arrival" required>
147
- </date-picker>
148
- ```
149
-
150
- The direct child text input is required. On enhancement its original `name` moves to an internal hidden input that submits the canonical ISO value.
151
-
152
- ### Attributes / properties
153
-
154
- - `value` — canonical selected date;
155
- - `locale`;
156
- - `min`;
157
- - `max`;
158
- - `open-on-focus` — boolean (default `true`): open the popover when the input receives focus without moving focus; `open-on-focus="false"` keeps focus alone passive;
159
- - `month-format` — `long | short` (default `long`): visible month-name style forwarded to the popup calendar;
160
- - `open` — read-only: whether the popover is shown;
161
- - `openOnFocus` — property form of `open-on-focus`.
162
-
163
- ### Native form semantics
164
-
165
- - `disabled` — non-editable, popup cannot open, field is not submitted.
166
- - `readonly` — non-editable, value is still submitted, popup cannot open (the trigger is disabled).
167
- - Clearing the editable input clears the canonical value; the component does not render its own clear button.
168
- - `input.defaultValue` (or the input's `value` attribute) is the source of truth for the next `form.reset()`, which restores canonical + visible representations and revalidates.
169
-
170
- ### Forwarded callback properties
171
-
172
- - `source`;
173
- - `dateState`;
174
- - `renderDay`;
175
- - `isDateDisabled`;
176
- - `messages`.
177
-
178
- ### Popup positioning
179
-
180
- - `coordinateSpace` — JS-only, `"viewport"` by default.
181
-
182
- ```text
183
- "viewport"
184
- panel uses position: fixed
185
- general/default integration
186
-
187
- "document"
188
- panel uses position: absolute
189
- explicit optimization when the application knows the picker
190
- reference moves with root-page scrolling
191
- ```
192
-
193
- `document` follows the `@lekoala/floating` consumer contract: the picker panel is a top-layer popover, so its absolute positioning resolves against the initial containing block; the application remains responsible for knowing that the `<date-picker>` reference itself moves with the root document. Changing `coordinateSpace` while open configures the next opening, never the current one. See the `@lekoala/floating` documentation for the coordinate-space doctrine.
194
-
195
- ### Read-only properties
196
-
197
- ```js
198
- picker.input;
199
- picker.calendar;
200
- ```
201
-
202
- ### Methods
203
-
204
- ```js
205
- picker.show();
206
- picker.hide();
207
- await picker.validate();
208
- ```
209
-
210
- ### Events
211
-
212
- - `valuechange` — canonical value changed;
213
- - `open`;
214
- - `close`.
215
-
216
- Calendar selection also dispatches normal bubbling `input` and `change` events on the visible input.
217
-
218
- ### Time companions
219
-
220
- Date with one time:
221
-
222
- ```html
223
- <date-picker>
224
- <input name="appointment[date]">
225
- <input type="time" data-time-start name="appointment[time]">
226
- </date-picker>
227
- ```
228
-
229
- ```text
230
- appointment[date] = 2026-09-10
231
- appointment[time] = 09:30
232
- ```
233
-
234
- Date with from/to times on the same day:
235
-
236
- ```html
237
- <date-picker>
238
- <input name="appointment[date]">
239
- <input type="time" data-time-start name="appointment[from]">
240
- <input type="time" data-time-end name="appointment[to]">
241
- </date-picker>
242
- ```
243
-
244
- ```text
245
- appointment[date] = 2026-09-10
246
- appointment[from] = 09:30
247
- appointment[to] = 11:00
248
- ```
249
-
250
- The submit contract is: `date-picker` owns the civil date selection, the time inputs own their civil time values, the authored HTML `name`s own the submitted structure, and the server/application owns datetime composition plus timezone. The picker never constructs a combined datetime, timestamp or timezone-bearing value.
251
-
252
- Future range + time projection (not supported yet — range mode leaves marked times native with a console warning):
253
-
254
- ```text
255
- period[start][date]
256
- period[start][time]
257
- period[end][date]
258
- period[end][time]
259
- ```
260
-
261
- Optional native time companions (one or both). They are direct children, fixed at connect time, and stay fully native: no hidden input, no `picker.time` property, no `timechange` event — their `input`/`change` events bubble like the date field's. `value` stays a `YYYY-MM-DD` date. The picker only enforces from/to order (`start <= end`, equality allowed, see [D5](DECISIONS.md)); a missing, empty or `disabled` time lifts the constraint, and `validate()` aggregates date validity, native time validity and that order. Range mode does not support time companions yet: marked times there are left native with a console warning.
262
-
263
- ## `<date-picker range>` — shared range surface
264
-
265
- ```html
266
- <date-picker range>
267
- <input data-range-start name="arrival" aria-label="Arrival">
268
- <input data-range-end name="departure" aria-label="Departure">
269
- </date-picker>
270
- ```
271
-
272
- Two editable fields share one calendar popup. The mode is fixed at connect time (`range` attribute + both marked direct child inputs); switching single/range at runtime is not supported in V1.
273
-
274
- Each field gets its own calendar trigger, inserted right after it (`start`, then its trigger, `end`, then its trigger), so DOM order matches visual/tab order. Both triggers point at the same popup; activating the other bound's trigger while it is open switches the active bound and keeps it open.
275
-
276
- ### Strict API
277
-
278
- - `range` — atomic get/set of `{ start, end }` (canonical `YYYY-MM-DD`). The setter validates through `normalizeRange` (ISO-only, ordered; inverted throws) and applies both bounds, the visible fields and both hidden fields **before** one `rangechange` event — no intermediate new-start/old-end state is observable.
279
- - `value` — `undefined` in range mode; assigning throws a `TypeError`.
280
- - `input` — `undefined` in range mode; the authored inputs remain the consumer-facing fields.
281
- - `startValue` / `endValue` — deferred; do not exist yet.
282
-
283
- Each field keeps its own name, hidden ISO input and validation. There is no `rangeDrop`-style polymorphic pair.
284
-
285
- ### `rangechange`
286
-
287
- ```js
288
- picker.addEventListener("rangechange", (event) => {
289
- console.log(event.detail); // { start, end } — may be incomplete or inverted
290
- });
291
- ```
292
-
293
- The business range may be temporarily incomplete or inverted; `calendar.highlightedRange` only ever receives a displayable (ordered) range.
294
-
295
- ### Interaction
296
-
297
- - When an ordered range is complete, picking before its start updates only the start; picking after its end updates only the end, regardless of the opening field. The popup closes after this update. An uneditable target bound refuses the activation.
298
- - Otherwise, opening through a field targets that bound (`activeEndpoint`). Picking from `start` commits it, moves the endpoint to `end` without closing, and a second pick commits and closes.
299
- - **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)).
300
- - 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.
301
- - **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.
302
- - **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.
303
- - 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.
304
- - 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.
305
- - 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.
306
-
307
- ## `linkDateRange(start, end)`
308
-
309
- ```js
310
- const cleanup = linkDateRange(startPicker, endPicker);
311
- ```
312
-
313
- Keeps effective start/end bounds synchronized and revalidates both inputs. Returns an idempotent-style cleanup function (call once to detach the relationship).
314
-
315
- ## `normalizeRange` and `rangePosition`
316
-
317
- ```js
318
- import { normalizeRange, rangePosition } from "@lekoala/date-picker";
319
-
320
- normalizeRange({ start: "2026-09-10", end: "2026-09-15" }); // { start, end }
321
- normalizeRange(null); // { start: "", end: "" }
322
- rangePosition("2026-09-12", range); // "start" | "in" | "end" | "single" | ""
323
- ```
324
-
325
- `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)).
326
-
327
- ## `DateRangeController`
328
-
329
- The pure two-bound state machine behind `date-picker[range]`. It owns the
330
- selection rules and knows nothing about DOM, popups or async availability.
331
-
332
- ```js
333
- const model = new DateRangeController();
334
- model.focus("start");
335
- model.activate("2026-09-10"); // { status: "pending", endpoint: "start", … }
336
- model.previewRange("2026-09-05"); // { start: "2026-09-05", end: "2026-09-10" }
337
- model.activate("2026-09-05"); // changedEndpoints: ["start", "end"]
338
- ```
339
-
340
- - `project(date, editable?)` / `projectEndpoint(date, endpoint, editable?)` describe a transition without applying it;
341
- - `activate(date, editable?)` / `moveEndpoint(date, endpoint, editable?)` apply one;
342
- - `previewRange(date, endpoint?, editable?)` returns the displayable band a candidate would produce, or `null` when there is nothing to promise.
343
-
344
- A transition is `{ status, endpoint, changedEndpoints, range, activeEndpoint, close? }`.
345
- `range` is the whole resulting pair, because one interaction can move both
346
- bounds; `changedEndpoints` names the ones that actually move. Every rule exists
347
- once, as a projection, and the previews read the same projections the commit
348
- applies.
349
-
350
- ## `createDateAdapter(locale)`
351
-
352
- Locale-aware editable numeric adapter:
353
-
354
- ```js
355
- const adapter = createDateAdapter("fr-BE");
356
- adapter.placeholder; // DD/MM/YYYY
357
- adapter.format("2026-09-06"); // 06/09/2026
358
- adapter.parse("06/09/2026"); // 2026-09-06
359
- ```
360
-
361
- ISO input is accepted as a fallback even when the locale display is different.
362
-
363
- ## Locales
364
-
365
- Control-message dictionaries ship per locale and can be attached through the `messages` property:
366
-
367
- ```js
368
- import fr from "@lekoala/date-picker/locales/fr";
369
- calendar.messages = fr;
370
- ```
371
-
372
- Available tags: `ar`, `de`, `en`, `es`, `fr`, `hi`, `id`, `it`, `ja`, `ko`, `nl`, `pl`, `pt-BR`, `pt-PT`, `ru`, `tr`, `zh-CN`. `en` mirrors the built-in defaults.
373
-
374
- Only the short control labels (choose/change date, month/year names, grid heading, validation messages, format hint, week heading) are locale data. Everything locale-shaped on screen — month/weekday names, the editable pattern and placeholder, parsing and digit normalization — comes from `Intl` via the `locale` attribute, so a dictionary is never required for a new language to display correctly.
375
-
376
- ## `dates`
377
-
378
- Pure helpers are exported as a namespace:
379
-
380
- ```js
381
- import { dates } from "@lekoala/date-picker";
382
- ```
383
-
384
- Public helpers:
385
-
386
- - `isLeapYear`
387
- - `daysInMonth`
388
- - `parseDate`
389
- - `isDate`
390
- - `monthKey`
391
- - `toISODate`
392
- - `compareDates`
393
- - `addDays`
394
- - `addMonths`
395
- - `addYears`
396
- - `shiftMonth`
397
- - `startOfMonth`
398
- - `endOfMonth`
399
- - `dayOfWeek`
400
- - `startOfWeek`
401
- - `endOfWeek`
402
- - `normalizeFirstDay`
403
- - `getMonthWeeks`
404
- - `clampDate`
405
- - `todayISO`
406
- - `isoWeekNumber`
407
-
408
- `normalizeFirstDay(firstDay)` maps `7` to `0` (Sunday) and returns `0..6` unchanged; any other value throws a `RangeError`. It backs `startOfWeek`, `getMonthWeeks` and `weekdayNames`, so those accept `7` as the ISO-style Sunday alias `@lekoala/calendar` uses while `2..6` keep their current meaning.
1
+ # API
2
+
3
+ The public surface — flat event names, `selection` values, `--dp-*` styling tokens and the `dateactivate`/`renderDay()` seams — is frozen in [DECISIONS.md](DECISIONS.md). Everything else stays internal and is free to change within the same major version.
4
+
5
+ ## `<date-calendar>`
6
+
7
+ ### Attributes / properties
8
+
9
+ - `controlClasses` — JS-only `{ month, year, nav }` partial class map; see header control styling below.
10
+
11
+ | Name | Type | Default | Meaning |
12
+ |---------------------|------------------|--------------------|----------------------------------------------|
13
+ | `value` | `YYYY-MM-DD` | `""` | Selected date. |
14
+ | `display` | `YYYY-MM` | value/today month | Month being rendered. |
15
+ | `min` | `YYYY-MM-DD` | `""` | Earliest activatable date. |
16
+ | `max` | `YYYY-MM-DD` | `""` | Latest activatable date. |
17
+ | `first-day` | `0..6` (`7` alias) | `1` | First weekday; Sunday=0, Monday=1. `7` is accepted as an ISO-style alias of Sunday (`@lekoala/calendar` uses 1..7) and never shifts the grid. Other values fall back to Monday. |
18
+ | `locale` | BCP 47 string | document/navigator | `Intl` locale. |
19
+ | `month-format` | `long \| short` | `long` | Visible month-name style in the month select; anything else falls back to `long`. The accessible grid heading always keeps the long month/year form. |
20
+ | `selection` | `single \| none` | `single` | Whether accepted activation updates `value`. |
21
+ | `fixed-weeks` | boolean | false | Request six full rows, limited by canonical date bounds. |
22
+ | `show-week-numbers` | boolean | false | Show ISO week numbers. The column renders only when `first-day === 1` (Monday-first): the ISO week and the displayed week only coincide then. With Sunday-first (`0`/`7`) the column is silently withheld. |
23
+
24
+ `focusedDate` and `highlightedRange` are properties rather than reflected attributes.
25
+
26
+ ### `highlightedRange`
27
+
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
+
30
+ ### Callback properties
31
+
32
+ ```js
33
+ calendar.isDateDisabled = (date) => boolean;
34
+ calendar.dateState = (date, sourceState) => ({
35
+ disabled,
36
+ enabled, // true = lifts isDateDisabled() only; never min/max or resolved disabled
37
+ description,
38
+ ...applicationMetadata
39
+ });
40
+ calendar.renderDay = (date, state) => Node | string | null;
41
+ ```
42
+
43
+ `renderDay` decorates the day cell; it cannot replace the interactive cell. The returned node is placed inside a component-owned, non-interactive container that is `aria-hidden`. That container has no stable class name: style the nodes you return, not the component's internals (see [DECISIONS.md](DECISIONS.md#d3--css--dp--is-public--dp--is-not)).
44
+
45
+ ### Source
46
+
47
+ ```js
48
+ calendar.source = async ({ start, end }, { signal }) => payload;
49
+ ```
50
+
51
+ or:
52
+
53
+ ```js
54
+ calendar.source = {
55
+ load({ start, end }, { signal }) { ... }
56
+ };
57
+ ```
58
+
59
+ ### Methods
60
+
61
+ ```js
62
+ calendar.previousMonth();
63
+ calendar.nextMonth();
64
+ calendar.focusDate("2026-09-10");
65
+ calendar.focusGrid();
66
+ calendar.getDateState("2026-09-10");
67
+ await calendar.activateDate("2026-09-10");
68
+ await calendar.ensureDate("2026-09-10");
69
+ await calendar.refreshSource();
70
+ calendar.render();
71
+ ```
72
+
73
+ ### Events
74
+
75
+ #### `dateactivate`
76
+
77
+ Cancelable. Fired for an allowed date after explicit click / Enter / Space.
78
+
79
+ ```js
80
+ calendar.addEventListener("dateactivate", (event) => {
81
+ console.log(event.detail.date, event.detail.state);
82
+ });
83
+ ```
84
+
85
+ Cancel it to refuse the activation before selection:
86
+
87
+ ```js
88
+ calendar.addEventListener("dateactivate", (event) => {
89
+ if (applicationRule(event.detail.date)) event.preventDefault();
90
+ });
91
+ ```
92
+
93
+ `activateDate(date)` runs that same path on demand: availability check, then
94
+ the cancelable event, then selection. Click, keyboard and the range endpoint
95
+ drag all go through it, so an application that cancels `dateactivate` cannot be
96
+ bypassed by a different input device.
97
+
98
+ #### `datefocus`
99
+
100
+ Fired when a date cell actually receives focus, with `{ date, state }`. Arrow,
101
+ Home, End, PageUp and PageDown all emit one through the cell that took focus.
102
+
103
+ ```js
104
+ calendar.addEventListener("datefocus", (event) => {
105
+ console.log(event.detail.date);
106
+ });
107
+ ```
108
+
109
+ It observes navigation and nothing else: a disabled day still reports it (grid
110
+ navigation stays discoverable), hover never does, and assigning `focusedDate`
111
+ without moving DOM focus stays silent. The three verbs are distinct —
112
+ `datefocus` observes, `dateactivate` requests an activation, `datechange`
113
+ observes a selection.
114
+
115
+ #### `datechange`
116
+
117
+ Fired when `selection="single"` changes `value`.
118
+
119
+ #### `dateinvalid`
120
+
121
+ Fired when the user tries to activate a disabled/out-of-range date.
122
+
123
+ #### `displaychange`
124
+
125
+ ```js
126
+ {
127
+ display: "2026-10",
128
+ start: "2026-09-28",
129
+ end: "2026-11-08"
130
+ }
131
+ ```
132
+
133
+ The range reflects rendered cells (and therefore includes six-row padding when enabled).
134
+
135
+ #### Source lifecycle
136
+
137
+ - `dateloadstart`
138
+ - `dateloadend`
139
+ - `dateloaderror`
140
+
141
+ ## `<date-picker>`
142
+
143
+ Markup:
144
+
145
+ ```html
146
+ <label for="arrival">Arrival</label>
147
+ <date-picker value="2026-09-10" locale="en-GB">
148
+ <input id="arrival" name="arrival" required>
149
+ </date-picker>
150
+ ```
151
+
152
+ The direct child text input is required. On enhancement its original `name` moves to an internal hidden input that submits the canonical ISO value.
153
+
154
+ ### Attributes / properties
155
+
156
+ - `controlClasses` — JS-only `{ month, year, nav }` partial class map; see header control styling below.
157
+
158
+ - `value` — canonical selected date;
159
+ - `locale`;
160
+ - `min`;
161
+ - `max`;
162
+ - `open-on-focus` — boolean (default `true`): open the popover when the input receives focus without moving focus; `open-on-focus="false"` keeps focus alone passive;
163
+ - `month-format` — `long | short` (default `long`): visible month-name style forwarded to the popup calendar;
164
+ - `open` — read-only: whether the popover is shown;
165
+ - `openOnFocus` — property form of `open-on-focus`.
166
+
167
+ ### Native form semantics
168
+
169
+ - `disabled` — non-editable, popup cannot open, field is not submitted.
170
+ - `readonly` — non-editable, value is still submitted, popup cannot open (the trigger is disabled).
171
+ - Clearing the editable input clears the canonical value; the component does not render its own clear button.
172
+ - `input.defaultValue` (or the input's `value` attribute) is the source of truth for the next `form.reset()`, which restores canonical + visible representations and revalidates.
173
+
174
+ ### Forwarded callback properties
175
+
176
+ - `source`;
177
+ - `dateState`;
178
+ - `renderDay`;
179
+ - `isDateDisabled`;
180
+ - `messages`.
181
+
182
+ ### Popup positioning
183
+
184
+ - `coordinateSpace` — JS-only, `"viewport"` by default.
185
+
186
+ ```text
187
+ "viewport"
188
+ panel uses position: fixed
189
+ general/default integration
190
+
191
+ "document"
192
+ panel uses position: absolute
193
+ explicit optimization when the application knows the picker
194
+ reference moves with root-page scrolling
195
+ ```
196
+
197
+ `document` follows the `@lekoala/floating` consumer contract: the picker panel is a top-layer popover, so its absolute positioning resolves against the initial containing block; the application remains responsible for knowing that the `<date-picker>` reference itself moves with the root document. Changing `coordinateSpace` while open configures the next opening, never the current one. See the `@lekoala/floating` documentation for the coordinate-space doctrine.
198
+
199
+ ### Read-only properties
200
+
201
+ ```js
202
+ picker.input;
203
+ picker.calendar;
204
+ ```
205
+
206
+ ### Methods
207
+
208
+ ```js
209
+ picker.show();
210
+ picker.hide();
211
+ await picker.validate();
212
+ ```
213
+
214
+ ### Events
215
+
216
+ - `valuechange` — canonical value changed;
217
+ - `open`;
218
+ - `close`.
219
+
220
+ Calendar selection also dispatches normal bubbling `input` and `change` events on the visible input.
221
+
222
+ ### Time companions
223
+
224
+ Date with one time:
225
+
226
+ ```html
227
+ <date-picker>
228
+ <input name="appointment[date]">
229
+ <input type="time" data-time-start name="appointment[time]">
230
+ </date-picker>
231
+ ```
232
+
233
+ ```text
234
+ appointment[date] = 2026-09-10
235
+ appointment[time] = 09:30
236
+ ```
237
+
238
+ Date with from/to times on the same day:
239
+
240
+ ```html
241
+ <date-picker>
242
+ <input name="appointment[date]">
243
+ <input type="time" data-time-start name="appointment[from]">
244
+ <input type="time" data-time-end name="appointment[to]">
245
+ </date-picker>
246
+ ```
247
+
248
+ ```text
249
+ appointment[date] = 2026-09-10
250
+ appointment[from] = 09:30
251
+ appointment[to] = 11:00
252
+ ```
253
+
254
+ The submit contract is: `date-picker` owns the civil date selection, the time inputs own their civil time values, the authored HTML `name`s own the submitted structure, and the server/application owns datetime composition plus timezone. The picker never constructs a combined datetime, timestamp or timezone-bearing value.
255
+
256
+ Future range + time projection (not supported yet — range mode leaves marked times native with a console warning):
257
+
258
+ ```text
259
+ period[start][date]
260
+ period[start][time]
261
+ period[end][date]
262
+ period[end][time]
263
+ ```
264
+
265
+ Optional native time companions (one or both). They are direct children, fixed at connect time, and stay fully native: no hidden input, no `picker.time` property, no `timechange` event — their `input`/`change` events bubble like the date field's. `value` stays a `YYYY-MM-DD` date. The picker only enforces from/to order (`start <= end`, equality allowed, see [D5](DECISIONS.md)); a missing, empty or `disabled` time lifts the constraint, and `validate()` aggregates date validity, native time validity and that order. Range mode does not support time companions yet: marked times there are left native with a console warning.
266
+
267
+ ## `<date-picker range>` — shared range surface
268
+
269
+ ```html
270
+ <date-picker range>
271
+ <input data-range-start name="arrival" aria-label="Arrival">
272
+ <input data-range-end name="departure" aria-label="Departure">
273
+ </date-picker>
274
+ ```
275
+
276
+ Two editable fields share one calendar popup. The mode is fixed at connect time (`range` attribute + both marked direct child inputs); switching single/range at runtime is not supported in V1.
277
+
278
+ Each field gets its own calendar trigger, inserted right after it (`start`, then its trigger, `end`, then its trigger), so DOM order matches visual/tab order. Both triggers point at the same popup; activating the other bound's trigger while it is open switches the active bound and keeps it open.
279
+
280
+ ### Strict API
281
+
282
+ - `range` — atomic get/set of `{ start, end }` (canonical `YYYY-MM-DD`). The setter validates through `normalizeRange` (ISO-only, ordered; inverted throws) and applies both bounds, the visible fields and both hidden fields **before** one `rangechange` event — no intermediate new-start/old-end state is observable.
283
+ - `value` — `undefined` in range mode; assigning throws a `TypeError`.
284
+ - `input` — `undefined` in range mode; the authored inputs remain the consumer-facing fields.
285
+ - `startValue` / `endValue` — deferred; do not exist yet.
286
+
287
+ Each field keeps its own name, hidden ISO input and validation. There is no `rangeDrop`-style polymorphic pair.
288
+
289
+ ### `rangechange`
290
+
291
+ ```js
292
+ picker.addEventListener("rangechange", (event) => {
293
+ console.log(event.detail); // { start, end } — may be incomplete or inverted
294
+ });
295
+ ```
296
+
297
+ The business range may be temporarily incomplete or inverted; `calendar.highlightedRange` only ever receives a displayable (ordered) range.
298
+
299
+ ### Interaction
300
+
301
+ - When an ordered range is complete, picking before its start updates only the start; picking after its end updates only the end, regardless of the opening field. The popup closes after this update. An uneditable target bound refuses the activation.
302
+ - Otherwise, opening through a field targets that bound (`activeEndpoint`). Picking from `start` commits it, moves the endpoint to `end` without closing, and a second pick commits and closes.
303
+ - **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)).
304
+ - 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.
305
+ - **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.
306
+ - **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.
307
+ - 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.
308
+ - 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.
309
+ - 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.
310
+
311
+ ## `linkDateRange(start, end)`
312
+
313
+ ```js
314
+ const cleanup = linkDateRange(startPicker, endPicker);
315
+ ```
316
+
317
+ Keeps effective start/end bounds synchronized and revalidates both inputs. Returns an idempotent-style cleanup function (call once to detach the relationship).
318
+
319
+ ## `normalizeRange` and `rangePosition`
320
+
321
+ ```js
322
+ import { normalizeRange, rangePosition } from "@lekoala/date-picker";
323
+
324
+ normalizeRange({ start: "2026-09-10", end: "2026-09-15" }); // { start, end }
325
+ normalizeRange(null); // { start: "", end: "" }
326
+ rangePosition("2026-09-12", range); // "start" | "in" | "end" | "single" | ""
327
+ ```
328
+
329
+ `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)).
330
+
331
+ ## `DateRangeController`
332
+
333
+ The pure two-bound state machine behind `date-picker[range]`. It owns the
334
+ selection rules and knows nothing about DOM, popups or async availability.
335
+
336
+ ```js
337
+ const model = new DateRangeController();
338
+ model.focus("start");
339
+ model.activate("2026-09-10"); // { status: "pending", endpoint: "start", … }
340
+ model.previewRange("2026-09-05"); // { start: "2026-09-05", end: "2026-09-10" }
341
+ model.activate("2026-09-05"); // changedEndpoints: ["start", "end"]
342
+ ```
343
+
344
+ - `project(date, editable?)` / `projectEndpoint(date, endpoint, editable?)` describe a transition without applying it;
345
+ - `activate(date, editable?)` / `moveEndpoint(date, endpoint, editable?)` apply one;
346
+ - `previewRange(date, endpoint?, editable?)` returns the displayable band a candidate would produce, or `null` when there is nothing to promise.
347
+
348
+ A transition is `{ status, endpoint, changedEndpoints, range, activeEndpoint, close? }`.
349
+ `range` is the whole resulting pair, because one interaction can move both
350
+ bounds; `changedEndpoints` names the ones that actually move. Every rule exists
351
+ once, as a projection, and the previews read the same projections the commit
352
+ applies.
353
+
354
+ ## `createDateAdapter(locale)`
355
+
356
+ Locale-aware editable numeric adapter:
357
+
358
+ ```js
359
+ const adapter = createDateAdapter("fr-BE");
360
+ adapter.placeholder; // DD/MM/YYYY
361
+ adapter.format("2026-09-06"); // 06/09/2026
362
+ adapter.parse("06/09/2026"); // 2026-09-06
363
+ ```
364
+
365
+ ISO input is accepted as a fallback even when the locale display is different.
366
+
367
+ ## Locales
368
+
369
+ `setDefaultMessages(partial)` merges global defaults and refreshes connected elements without a messages override, including picker field validation, triggers and popup labels. `getDefaultMessages()` returns a copy. Assigning `element.messages` merges that assignment with the current global defaults, not with the previous assignment. Assigning `null` resumes following global defaults.
370
+
371
+ Control-message dictionaries ship per locale and can be attached through the `messages` property:
372
+
373
+ ```js
374
+ import fr from "@lekoala/date-picker/locales/fr";
375
+ calendar.messages = fr;
376
+ ```
377
+
378
+ Available tags: `ar`, `de`, `en`, `es`, `fr`, `hi`, `id`, `it`, `ja`, `ko`, `nl`, `pl`, `pt-BR`, `pt-PT`, `ru`, `tr`, `zh-CN`. `en` mirrors the built-in defaults.
379
+
380
+ Only the short control labels (choose/change date, month/year names, grid heading, validation messages, format hint, week heading) are locale data. Everything locale-shaped on screen — month/weekday names, the editable pattern and placeholder, parsing and digit normalization — comes from `Intl` via the `locale` attribute, so a dictionary is never required for a new language to display correctly.
381
+
382
+ ## `dates`
383
+
384
+ Pure helpers are exported as a namespace:
385
+
386
+ ```js
387
+ import { dates } from "@lekoala/date-picker";
388
+ ```
389
+
390
+ Public helpers:
391
+
392
+ - `isLeapYear`
393
+ - `daysInMonth`
394
+ - `parseDate`
395
+ - `isDate`
396
+ - `monthKey`
397
+ - `toISODate`
398
+ - `compareDates`
399
+ - `addDays`
400
+ - `addMonths`
401
+ - `addYears`
402
+ - `shiftMonth`
403
+ - `startOfMonth`
404
+ - `endOfMonth`
405
+ - `dayOfWeek`
406
+ - `startOfWeek`
407
+ - `endOfWeek`
408
+ - `normalizeFirstDay`
409
+ - `getMonthWeeks`
410
+ - `clampDate`
411
+ - `todayISO`
412
+ - `isoWeekNumber`
413
+
414
+ `normalizeFirstDay(firstDay)` maps `7` to `0` (Sunday) and returns `0..6` unchanged; any other value throws a `RangeError`. It backs `startOfWeek`, `getMonthWeeks` and `weekdayNames`, so those accept `7` as the ISO-style Sunday alias `@lekoala/calendar` uses while `2..6` keep their current meaning.
415
+
416
+ ## Header control styling
417
+
418
+ `setDefaultControlClasses(partial)` and `getDefaultControlClasses()` are named package exports, alongside `setDefaultMessages()` / `getDefaultMessages()`. Class defaults are resolved per role when used and refresh connected elements with inherited roles. The getter returns a copy.
419
+
420
+ The same four functions are available as static methods on both `DateCalendarElement` and `DatePickerElement`. For the classic build, call them through the registry after the script has loaded:
421
+
422
+ ```js
423
+ const Picker = customElements.get("date-picker");
424
+ Picker.setDefaultControlClasses({ month: "select sm", year: "input sm", nav: "btn outline sm icon-only" });
425
+ Picker.setDefaultMessages({ chooseDate: "Choose a date" });
426
+ const classes = Picker.getDefaultControlClasses();
427
+ const messages = customElements.get("date-calendar").getDefaultMessages();
428
+ ```
429
+
430
+ Both constructors delegate to the same defaults as the named ESM exports within that build. Class defaults update inherited roles on connected elements; explicitly assigned roles stay unchanged. Message defaults update elements without a messages override. All writable public properties assigned before definition are replayed through their setters on connection. Picker `value` and `range` are replayed after its fields and controllers are initialized; `range` still requires the `range` attribute. Read-only properties remain read-only.
431
+
432
+ Both elements expose `controlClasses`. Assignment stores only the normalized roles provided, replacing the previous partial map. Each omitted role follows its current global default when read or rendered, regardless of assignment order. An empty string explicitly restores the default skin for a role; `null` resumes following global defaults. The picker forwards only its own partial map to its shared calendar, including in range mode. There is no HTML attribute.
433
+
434
+ Each non-empty role uses only the author's skin (including caret, hover, disabled and focus). Grid layout, year track width and month truncation stay component-owned. Day cells and the picker trigger are excluded. Class lists are whitespace-normalized and deduplicated; tokens containing control characters or using the reserved `dp-*` namespace are ignored. Arbitrary utility classes such as `[&>svg]:size-4`, `data-[state=open]:bg-muted` and `aria-[disabled=true]:opacity-50` are preserved. Values are HTML-escaped on rendering, including quotes and HTML delimiters. Reassigning equivalent normalized classes leaves the rendered controls and grid intact.
435
+
436
+ ## Render snapshots and range previews
437
+
438
+ A full `calendar.render()` resolves each visible day's state once through `dateState` and, unless explicitly enabled, `isDateDisabled`. The grid and `renderDay` use that same state snapshot. Range-band updates reuse it without calling these callbacks again. If a callback reads mutable application data, call `calendar.render()` after changing that data. Assigning callbacks, changing calendar options or completing a source load already rebuilds the snapshot. Activation still reads current state and checks source availability.
439
+
440
+ `fixed-weeks` requests six complete rows. At the end of the canonical date window, December 9999 with Saturday as the first weekday renders five rows rather than padding beyond `9999-12-31`. `dates.getMonthWeeks(value, { firstDay, fixedWeeks: true })` uses the same rule; its default remains unpadded.
441
+
442
+ `createFetchSource()` resolves and validates its URL when the adapter is created. Relative URLs use `document.baseURI` in a document or `globalThis.location.href` in a worker. Without either base, an absolute URL is required and a relative URL throws an explicit `TypeError` immediately. There is no implicit localhost base. Cancellation and optional timeouts belong to the caller's `AbortSignal`.