@lekoala/date-picker 0.5.1 → 0.5.2

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/DECISIONS.md CHANGED
@@ -1,74 +1,74 @@
1
- # Decisions
2
-
3
- Frozen contracts for the public surface. A change to a decision below is a breaking change and needs a proposal, not an opportunistic edit.
4
-
5
- ## D1 — Calendar events are flat, verb-named
6
-
7
- Never merged with the `value` concept (`dateactivate` is activation, not `value`).
8
-
9
- - `date-calendar`: `dateactivate`, `datefocus`, `datechange`, `dateinvalid`, `displaychange`, `dateloadstart`, `dateloadend`, `dateloaderror`.
10
- - `date-picker`: `valuechange`, `open`, `close`, plus `rangechange` in range mode.
11
-
12
- The observation verbs stay distinct: `datefocus` observes navigation, `dateactivate` requests an activation (and is cancelable), `datechange` observes a selection. `datefocus` is named after the user event rather than after the `focusedDate` setter, so a programmatic assignment that moves no DOM focus never announces one.
13
-
14
- No `dp:` namespace prefix on element events, even though `@lekoala/combobox` namespaces its events (`combobox:*`).
15
-
16
- Rationale: the flat names describe a distinct concept (`dateactivate`), never collide with native input events, and are already wired through the docs, demos and tests. A namespace is not worth the churn for this package.
17
-
18
- ## D2 — `selection="none"` is the final spelling
19
-
20
- The navigation-only calendar keeps `selection="none"`.
21
-
22
- `selection` remains a value attribute (`single` | `none`); it is how the mini calendar speaks the navigation contract: accepted activation dispatches `dateactivate`, `value` is untouched.
23
-
24
- ## D3 — CSS: `--dp-*` is public, `.dp-*` is not
25
-
26
- > `--dp-*` is the public styling namespace. `.dp-*` identifies component-owned DOM but is not a public styling API unless explicitly documented.
27
-
28
- - The namespace prefix is `dp`, final. There are no `--lko-dp-*` or `.lko-dp-*` classes. A shared `--lko-*` token space is reserved for tokens actually shared between LeKoala packages, not as a per-component prefix.
29
- - The `--dp-*` custom properties (background, foreground, accent, border, radius, day-size, gap, …) are the **public customization surface**. They are listed in `custom-elements.json` and are stable.
30
- - Every `.dp-*` class is **internal DOM** and may change, move or disappear without a semver bump. This includes `.dp-day`, `.dp-grid`, `.dp-picker-panel` and the day-extra container.
31
- - `renderDay()` is a public extension seam, but the name of the container its output is placed into is **not** promised. Consumers style their own returned nodes and assign their own class names.
32
- - Demos, tests and internal code may reference `.dp-*` classes; their use there does not turn those classes into public API.
33
-
34
- Context: DOM-backed styling hooks become API the moment they are documented — every internal rename then breaks consumers. We prefer to expose hooks (`--dp-*`, `renderDay()`, events) and keep the rendered anatomy free to evolve.
35
-
36
- ## D4 — `<date-picker>` opens on focus; `open` is read-only
37
-
38
- - The picker opens its popover when the input receives focus (`open-on-focus`, default `true`; set `open-on-focus="false"` to opt out).
39
- - Opening on focus **never steals keyboard focus**: the user can start typing immediately. The grid takes focus only on ArrowDown, on the calendar trigger, or after the user moves the pointer into the popup.
40
- - Closing returns focus to the control that opened the popover (field or trigger) — the usual popover convention. Range mode is the exception: focus follows the active bound, because a first pick moves the workflow to the other bound.
41
- - The calendar trigger is overlaid inside the field's own box, like the native `input[type=time]` indicator. The field reserves its place (`padding-inline-end`), keeps its border/background, and owns the focus ring for the pair; the trigger is a real button with an inner ring of its own. A range bound owns its own trigger; the two share one popup, calendar, source and state.
42
- - `show()` / `hide()` are the control surface. `open` is a **read-only getter** reflecting the popover. There is no `open` attribute: the popover element is the single machine of truth and a second state machine would only drift.
43
- - `readonly` mirrors native semantics: the field is non-editable, the value is still submitted, and the picker cannot change it — the calendar trigger is disabled. `disabled` additionally unsubmits the field. `readonly` is never used to mean "no typing, but calendar selection allowed".
44
- - Clearing the editable input clears the canonical value (the component renders no internal clear button). Form resets follow the native `defaultValue` contract. This matches the Open UI datepicker `clear` concept: clearing is a text-editing action, not a component-owned affordance.
45
-
46
- ## D5 — Time is a native companion, not picker state
47
-
48
- - Time fields are authored native `input[type=time]` marked `[data-time-start]` / `[data-time-end]`; `name`, `value`, `required`, `min`, `max`, `step` and submission stay native. There is no hidden time input and no time field controller.
49
- - `picker.value` / `picker.range` remain date-only (`YYYY-MM-DD`); the picker never constructs a datetime, a timestamp or a timezone-bearing value.
50
- - The picker only coordinates from/to order (`start <= end`, equality allowed); duration rules stay application-owned.
51
- - Custom time UI and slot picking stay outside the package.
52
-
53
- ## D6 — Calendar interaction never produces an inverted range
54
-
55
- Pointing and typing are different contracts, deliberately.
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.
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
- - 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
- - 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.
63
-
64
- ## D7 — Author classes own header control styling
65
-
66
- `controlClasses` and `setDefaultControlClasses()` are the public hooks for styling the month, year and navigation roles with a host design system. A role with non-empty author classes receives no library skin, including in forced colors. Layout remains component-owned. Roles without author classes retain the default skin.
67
-
68
- The `.dp-*` namespace stays internal (D3); it is reserved and ignored in author class lists. Day grid cells and the picker trigger are outside this API. There is no HTML attribute.
69
-
70
- ## D8 — Defaults are accessible through registered constructors
71
-
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
-
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.
1
+ # Decisions
2
+
3
+ Frozen contracts for the public surface. A change to a decision below is a breaking change and needs a proposal, not an opportunistic edit.
4
+
5
+ ## D1 — Calendar events are flat, verb-named
6
+
7
+ Never merged with the `value` concept (`dateactivate` is activation, not `value`).
8
+
9
+ - `date-calendar`: `dateactivate`, `datefocus`, `datechange`, `dateinvalid`, `displaychange`, `dateloadstart`, `dateloadend`, `dateloaderror`.
10
+ - `date-picker`: `valuechange`, `open`, `close`, plus `rangechange` in range mode.
11
+
12
+ The observation verbs stay distinct: `datefocus` observes navigation, `dateactivate` requests an activation (and is cancelable), `datechange` observes a selection. `datefocus` is named after the user event rather than after the `focusedDate` setter, so a programmatic assignment that moves no DOM focus never announces one.
13
+
14
+ No `dp:` namespace prefix on element events, even though `@lekoala/combobox` namespaces its events (`combobox:*`).
15
+
16
+ Rationale: the flat names describe a distinct concept (`dateactivate`), never collide with native input events, and are already wired through the docs, demos and tests. A namespace is not worth the churn for this package.
17
+
18
+ ## D2 — `selection="none"` is the final spelling
19
+
20
+ The navigation-only calendar keeps `selection="none"`.
21
+
22
+ `selection` remains a value attribute (`single` | `none`); it is how the mini calendar speaks the navigation contract: accepted activation dispatches `dateactivate`, `value` is untouched.
23
+
24
+ ## D3 — CSS: `--dp-*` is public, `.dp-*` is not
25
+
26
+ > `--dp-*` is the public styling namespace. `.dp-*` identifies component-owned DOM but is not a public styling API unless explicitly documented.
27
+
28
+ - The namespace prefix is `dp`, final. There are no `--lko-dp-*` or `.lko-dp-*` classes. A shared `--lko-*` token space is reserved for tokens actually shared between LeKoala packages, not as a per-component prefix.
29
+ - The `--dp-*` custom properties (background, foreground, accent, border, radius, day-size, gap, …) are the **public customization surface**. They are listed in `custom-elements.json` and are stable.
30
+ - Every `.dp-*` class is **internal DOM** and may change, move or disappear without a semver bump. This includes `.dp-day`, `.dp-grid`, `.dp-picker-panel` and the day-extra container.
31
+ - `renderDay()` is a public extension seam, but the name of the container its output is placed into is **not** promised. Consumers style their own returned nodes and assign their own class names.
32
+ - Demos, tests and internal code may reference `.dp-*` classes; their use there does not turn those classes into public API.
33
+
34
+ Context: DOM-backed styling hooks become API the moment they are documented — every internal rename then breaks consumers. We prefer to expose hooks (`--dp-*`, `renderDay()`, events) and keep the rendered anatomy free to evolve.
35
+
36
+ ## D4 — `<date-picker>` opens on focus; `open` is read-only
37
+
38
+ - The picker opens its popover when the input receives focus (`open-on-focus`, default `true`; set `open-on-focus="false"` to opt out).
39
+ - Opening on focus **never steals keyboard focus**: the user can start typing immediately. The grid takes focus only on ArrowDown, on the calendar trigger, or after the user moves the pointer into the popup.
40
+ - Closing returns focus to the control that opened the popover (field or trigger) — the usual popover convention. Range mode is the exception: focus follows the active bound, because a first pick moves the workflow to the other bound.
41
+ - The calendar trigger is overlaid inside the field's own box, like the native `input[type=time]` indicator. The field reserves its place (`padding-inline-end`), keeps its border/background, and owns the focus ring for the pair; the trigger is a real button with an inner ring of its own. A range bound owns its own trigger; the two share one popup, calendar, source and state.
42
+ - `show()` / `hide()` are the control surface. `open` is a **read-only getter** reflecting the popover. There is no `open` attribute: the popover element is the single machine of truth and a second state machine would only drift.
43
+ - `readonly` mirrors native semantics: the field is non-editable, the value is still submitted, and the picker cannot change it — the calendar trigger is disabled. `disabled` additionally unsubmits the field. `readonly` is never used to mean "no typing, but calendar selection allowed".
44
+ - Clearing the editable input clears the canonical value (the component renders no internal clear button). Form resets follow the native `defaultValue` contract. This matches the Open UI datepicker `clear` concept: clearing is a text-editing action, not a component-owned affordance.
45
+
46
+ ## D5 — Time is a native companion, not picker state
47
+
48
+ - Time fields are authored native `input[type=time]` marked `[data-time-start]` / `[data-time-end]`; `name`, `value`, `required`, `min`, `max`, `step` and submission stay native. There is no hidden time input and no time field controller.
49
+ - `picker.value` / `picker.range` remain date-only (`YYYY-MM-DD`); the picker never constructs a datetime, a timestamp or a timezone-bearing value.
50
+ - The picker only coordinates from/to order (`start <= end`, equality allowed); duration rules stay application-owned.
51
+ - Custom time UI and slot picking stay outside the package.
52
+
53
+ ## D6 — Calendar interaction never produces an inverted range
54
+
55
+ Pointing and typing are different contracts, deliberately.
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.
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
+ - 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
+ - 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.
63
+
64
+ ## D7 — Author classes own header control styling
65
+
66
+ `controlClasses` and `setDefaultControlClasses()` are the public hooks for styling the month, year and navigation roles with a host design system. A role with non-empty author classes receives no library skin, including in forced colors. Layout remains component-owned. Roles without author classes retain the default skin.
67
+
68
+ The `.dp-*` namespace stays internal (D3); it is reserved and ignored in author class lists. Day grid cells and the picker trigger are outside this API. There is no HTML attribute.
69
+
70
+ ## D8 — Defaults are accessible through registered constructors
71
+
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
+
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.
package/docs/USE_CASES.md CHANGED
@@ -1,156 +1,156 @@
1
- # Use cases
2
-
3
- These cases define the product more strongly than a feature checklist. A proposed API change should be tested against them before being accepted.
4
-
5
- ## U1 — Editable localized date field
6
-
7
- A user can type `06/09/2026` in `fr-BE`, open the calendar, select the same date visually, and submit `2026-09-06` to the server.
8
-
9
- Acceptance:
10
-
11
- - input remains editable;
12
- - label still targets the real input;
13
- - required/custom validity still works;
14
- - display formatting does not leak into submitted data.
15
-
16
- ## U2 — Inline selector
17
-
18
- An inline calendar owns one selected date.
19
-
20
- Acceptance:
21
-
22
- - month/year navigation does not change the value;
23
- - arrow-key focus does not change the value;
24
- - click/Enter/Space activates and selects;
25
- - today, focused and selected are visually/semantically distinct.
26
-
27
- ## U3 — Mini calendar navigator
28
-
29
- An inline `selection="none"` calendar navigates a separate agenda/calendar.
30
-
31
- Acceptance:
32
-
33
- - there may be no selected value at all;
34
- - changing month/year only changes what the mini calendar displays;
35
- - activating a date dispatches `dateactivate` and the application calls `calendarView.gotoDate(date)`;
36
- - outside-month days remain activatable;
37
- - six-row height is available as presentation, not baked into month math;
38
- - ISO week numbers are available;
39
- - a narrow/compact presentation can use short month names (`month-format="short"`).
40
-
41
- ## U4 — Constrained dates
42
-
43
- Example: only the next 90 days, weekdays only.
44
-
45
- Acceptance:
46
-
47
- - min/max affect both activation and typed validation;
48
- - custom disabled days can still receive keyboard focus so the reason can be announced;
49
- - disabled activation never changes the value;
50
- - previous/next controls stop at bounded months.
51
-
52
- ## U5 — Linked start/end dates
53
-
54
- Two fields represent a range.
55
-
56
- Acceptance:
57
-
58
- - end cannot validate before start;
59
- - start cannot validate after end;
60
- - changing one side revalidates the other;
61
- - the component never silently rewrites the sibling value;
62
- - each picker remains independently usable and replaceable.
63
-
64
- ## U6 — Remote availability
65
-
66
- Changing the displayed month loads per-day state from a backend.
67
-
68
- Acceptance:
69
-
70
- - load receives the actual rendered range;
71
- - superseded navigation aborts the previous request;
72
- - state can disable a day or describe it;
73
- - loading logic is independent of URL/backend format;
74
- - a typed date can call `ensureDate()` before validation.
75
-
76
- ## U7 — Morning / afternoon indicators
77
-
78
- A date can carry richer state such as morning/afternoon availability.
79
-
80
- Acceptance:
81
-
82
- - the business fields remain opaque to core logic;
83
- - visual badges/dots live inside the owned day cell;
84
- - accessible meaning is supplied through `description`;
85
- - custom rendering cannot replace focus/ARIA semantics.
86
-
87
- ## U8 — Keyboard-only operation
88
-
89
- Everything above works without a pointing device.
90
-
91
- Acceptance:
92
-
93
- - one grid cell in the Tab sequence;
94
- - arrows, Home/End, PageUp/PageDown, Shift+PageUp/PageDown work;
95
- - changing display follows keyboard focus when focus crosses a month;
96
- - activation requires Enter/Space;
97
- - Escape closes the picker and restores a sensible focus target;
98
- - month/year selects remain normal native controls.
99
-
100
- ## U9 — Two fields, one shared calendar
101
-
102
- A form has two editable date fields (arrival/departure) that share a single calendar surface inside one picker popup.
103
-
104
- Acceptance:
105
-
106
- - each field stays independently editable and localized, with its own hidden ISO value for submission;
107
- - opening from a bound targets that bound: selecting a first date commits it, moves the active endpoint to the other bound without closing, and a second selection commits and closes;
108
- - once an ordered range is complete, activating before it extends the start and activating after it extends the end, regardless of the opening field; the opposite bound stays unchanged and readonly/disabled bounds remain protected;
109
- - a preloaded range shows the band on open and is never reordered;
110
- - month/year navigation and arrow-key focus never mutate either bound;
111
- - an inverted range (start after end) keeps both entered values and surfaces an order error on the bound that was just modified; changing either side revalidates both;
112
- - form reset restores both fields in one resync;
113
- - a stale async availability response arriving after the active endpoint changed never commits a value or closes the popup;
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
- - 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
- - only the bounds are validated; per-night availability stays application-owned.
118
-
119
- ## U10 — Date with native time companions
120
-
121
- A form pairs a date with one or two native times (appointment at `09:30`, from/to on the same day).
122
-
123
- Acceptance:
124
-
125
- - times stay native `input[type=time]` fields with their own names and submission;
126
- - `value` stays a `YYYY-MM-DD` date;
127
- - an inverted from/to surfaces an order error on the modified bound;
128
- - a missing or disabled time lifts the order constraint;
129
- - calendar selection and form reset keep the entered times.
130
-
131
- ## U11 — Host design system controls
132
-
133
- The calendar header uses application-owned control classes without styling internal `.dp-*` selectors.
134
-
135
- Acceptance:
136
-
137
- - author classes survive every render;
138
- - the library applies no border, background, caret, padding, hover, disabled or focus skin to a role with author classes;
139
- - controls stretch to equal heights, the year keeps its grid-track width and long month names truncate;
140
- - labels, keyboard navigation and focus restoration are unchanged;
141
- - controls without author classes keep the default skin.
142
-
143
- ## Not a use case for this package
144
-
145
- Do not grow the API to cover these without a separate design:
146
-
147
- - time slots;
148
- - duration picking;
149
- - timezone conversion;
150
- - appointment recurrence;
151
- - resource scheduling;
152
- - drag/drop;
153
- - event rendering;
154
- - multi-day scheduler views.
155
-
156
- Those belong to `@lekoala/calendar` or application logic.
1
+ # Use cases
2
+
3
+ These cases define the product more strongly than a feature checklist. A proposed API change should be tested against them before being accepted.
4
+
5
+ ## U1 — Editable localized date field
6
+
7
+ A user can type `06/09/2026` in `fr-BE`, open the calendar, select the same date visually, and submit `2026-09-06` to the server.
8
+
9
+ Acceptance:
10
+
11
+ - input remains editable;
12
+ - label still targets the real input;
13
+ - required/custom validity still works;
14
+ - display formatting does not leak into submitted data.
15
+
16
+ ## U2 — Inline selector
17
+
18
+ An inline calendar owns one selected date.
19
+
20
+ Acceptance:
21
+
22
+ - month/year navigation does not change the value;
23
+ - arrow-key focus does not change the value;
24
+ - click/Enter/Space activates and selects;
25
+ - today, focused and selected are visually/semantically distinct.
26
+
27
+ ## U3 — Mini calendar navigator
28
+
29
+ An inline `selection="none"` calendar navigates a separate agenda/calendar.
30
+
31
+ Acceptance:
32
+
33
+ - there may be no selected value at all;
34
+ - changing month/year only changes what the mini calendar displays;
35
+ - activating a date dispatches `dateactivate` and the application calls `calendarView.gotoDate(date)`;
36
+ - outside-month days remain activatable;
37
+ - six-row height is available as presentation, not baked into month math;
38
+ - ISO week numbers are available;
39
+ - a narrow/compact presentation can use short month names (`month-format="short"`).
40
+
41
+ ## U4 — Constrained dates
42
+
43
+ Example: only the next 90 days, weekdays only.
44
+
45
+ Acceptance:
46
+
47
+ - min/max affect both activation and typed validation;
48
+ - custom disabled days can still receive keyboard focus so the reason can be announced;
49
+ - disabled activation never changes the value;
50
+ - previous/next controls stop at bounded months.
51
+
52
+ ## U5 — Linked start/end dates
53
+
54
+ Two fields represent a range.
55
+
56
+ Acceptance:
57
+
58
+ - end cannot validate before start;
59
+ - start cannot validate after end;
60
+ - changing one side revalidates the other;
61
+ - the component never silently rewrites the sibling value;
62
+ - each picker remains independently usable and replaceable.
63
+
64
+ ## U6 — Remote availability
65
+
66
+ Changing the displayed month loads per-day state from a backend.
67
+
68
+ Acceptance:
69
+
70
+ - load receives the actual rendered range;
71
+ - superseded navigation aborts the previous request;
72
+ - state can disable a day or describe it;
73
+ - loading logic is independent of URL/backend format;
74
+ - a typed date can call `ensureDate()` before validation.
75
+
76
+ ## U7 — Morning / afternoon indicators
77
+
78
+ A date can carry richer state such as morning/afternoon availability.
79
+
80
+ Acceptance:
81
+
82
+ - the business fields remain opaque to core logic;
83
+ - visual badges/dots live inside the owned day cell;
84
+ - accessible meaning is supplied through `description`;
85
+ - custom rendering cannot replace focus/ARIA semantics.
86
+
87
+ ## U8 — Keyboard-only operation
88
+
89
+ Everything above works without a pointing device.
90
+
91
+ Acceptance:
92
+
93
+ - one grid cell in the Tab sequence;
94
+ - arrows, Home/End, PageUp/PageDown, Shift+PageUp/PageDown work;
95
+ - changing display follows keyboard focus when focus crosses a month;
96
+ - activation requires Enter/Space;
97
+ - Escape closes the picker and restores a sensible focus target;
98
+ - month/year selects remain normal native controls.
99
+
100
+ ## U9 — Two fields, one shared calendar
101
+
102
+ A form has two editable date fields (arrival/departure) that share a single calendar surface inside one picker popup.
103
+
104
+ Acceptance:
105
+
106
+ - each field stays independently editable and localized, with its own hidden ISO value for submission;
107
+ - opening from a bound targets that bound: selecting a first date commits it, moves the active endpoint to the other bound without closing, and a second selection commits and closes;
108
+ - once an ordered range is complete, activating before it extends the start and activating after it extends the end, regardless of the opening field; the opposite bound stays unchanged and readonly/disabled bounds remain protected;
109
+ - a preloaded range shows the band on open and is never reordered;
110
+ - month/year navigation and arrow-key focus never mutate either bound;
111
+ - an inverted range (start after end) keeps both entered values and surfaces an order error on the bound that was just modified; changing either side revalidates both;
112
+ - form reset restores both fields in one resync;
113
+ - a stale async availability response arriving after the active endpoint changed never commits a value or closes the popup;
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
+ - 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
+ - only the bounds are validated; per-night availability stays application-owned.
118
+
119
+ ## U10 — Date with native time companions
120
+
121
+ A form pairs a date with one or two native times (appointment at `09:30`, from/to on the same day).
122
+
123
+ Acceptance:
124
+
125
+ - times stay native `input[type=time]` fields with their own names and submission;
126
+ - `value` stays a `YYYY-MM-DD` date;
127
+ - an inverted from/to surfaces an order error on the modified bound;
128
+ - a missing or disabled time lifts the order constraint;
129
+ - calendar selection and form reset keep the entered times.
130
+
131
+ ## U11 — Host design system controls
132
+
133
+ The calendar header uses application-owned control classes without styling internal `.dp-*` selectors.
134
+
135
+ Acceptance:
136
+
137
+ - author classes survive every render;
138
+ - the library applies no border, background, caret, padding, hover, disabled or focus skin to a role with author classes;
139
+ - controls stretch to equal heights, the year keeps its grid-track width and long month names truncate;
140
+ - labels, keyboard navigation and focus restoration are unchanged;
141
+ - controls without author classes keep the default skin.
142
+
143
+ ## Not a use case for this package
144
+
145
+ Do not grow the API to cover these without a separate design:
146
+
147
+ - time slots;
148
+ - duration picking;
149
+ - timezone conversion;
150
+ - appointment recurrence;
151
+ - resource scheduling;
152
+ - drag/drop;
153
+ - event rendering;
154
+ - multi-day scheduler views.
155
+
156
+ Those belong to `@lekoala/calendar` or application logic.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lekoala/date-picker",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Native-first date picker and inline calendar primitives",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -143,7 +143,7 @@
143
143
  "typecheck": "tsc -p jsconfig.json",
144
144
  "types": "tsc -p tsconfig.types.json",
145
145
  "test": "bun test test/unit",
146
- "test:syntax": "node --check src/date.js && node --check src/intl.js && node --check src/calendar-model.js && node --check src/source.js && node --check src/messages.js && node --check src/control-classes.js && node --check src/upgrade-properties.js && node --check src/date-calendar.js && node --check src/date-field.js && node --check src/date-picker.js && node --check src/date-range.js && node --check src/time.js && node --check src/index.js && node --check src/define.js",
146
+ "test:syntax": "bun scripts/check-syntax.js",
147
147
  "test:types": "tsc -p test/types/tsconfig.json",
148
148
  "test:browser": "playwright test browser --project chromium",
149
149
  "test:browser:accessibility": "playwright test test/browser/picker.spec.js test/browser/focus.spec.js test/browser/calendar.spec.js test/browser/form-state.spec.js test/browser/surfaces.spec.js",
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Package-internal: the host intent each activation event carries. A host
3
+ * (`date-picker`) hands its intent to the next activation through
4
+ * `_hostIntent`; `activateDate()` takes it synchronously and tags the events
5
+ * it dispatches, so a late response is matched to its own interaction, never
6
+ * to an earlier or later activation of the same date.
7
+ * @type {WeakMap<Event, unknown>}
8
+ */
9
+ export const activationIntents = new WeakMap();
@@ -1,3 +1,4 @@
1
+ import { activationIntents } from "./activation-intents.js";
1
2
  import { CalendarModel } from "./calendar-model.js";
2
3
  import {
3
4
  getDefaultControlClasses,
@@ -165,6 +166,9 @@ export class DateCalendarElement extends HTMLElement {
165
166
  this._rendering = false;
166
167
  /** @type {DateSource | null} */
167
168
  this._source = null;
169
+ /** Intent the host attached to the next activation of `date`.
170
+ * @type {{date: string, intent: unknown} | null} */
171
+ this._hostIntent = null;
168
172
  this._sourceStates = new Map();
169
173
  this._loadedRanges = new Set();
170
174
  this._loadController = null;
@@ -537,7 +541,7 @@ export class DateCalendarElement extends HTMLElement {
537
541
  return { start: first[0], end: last[6] };
538
542
  }
539
543
 
540
- /** @public @param {string} date */
544
+ /** @public @param {string} date @returns {DateState & {disabled: boolean}} */
541
545
  getDateState(date) {
542
546
  const fromSource = this._sourceStates.get(date) || {};
543
547
  const local = this._dateState?.(date, { ...fromSource }) || {};
@@ -681,6 +685,16 @@ export class DateCalendarElement extends HTMLElement {
681
685
  }
682
686
  }
683
687
 
688
+ /** Leave exactly one roving tab stop, on `date`'s cell when rendered.
689
+ * @param {string} date */
690
+ _syncRovingDom(date) {
691
+ for (const cell of this.querySelectorAll('.dp-day[tabindex="0"]')) {
692
+ if (cell instanceof HTMLElement && cell.dataset.date !== date) cell.tabIndex = -1;
693
+ }
694
+ const cell = this.querySelector(`.dp-day[data-date="${CSS.escape(date)}"]`);
695
+ if (cell instanceof HTMLElement) cell.tabIndex = 0;
696
+ }
697
+
684
698
  /** @param {string} previousValue @param {string} nextValue */
685
699
  _moveSelectionDom(previousValue, nextValue) {
686
700
  if (previousValue) {
@@ -767,21 +781,30 @@ export class DateCalendarElement extends HTMLElement {
767
781
  * @param {string} date @returns {Promise<boolean>}
768
782
  */
769
783
  async activateDate(date) {
784
+ const hostIntent = this._hostIntent?.date === date ? this._hostIntent.intent : undefined;
785
+ this._hostIntent = null;
786
+ /** @param {Event} event */
787
+ const tagged = (event) => {
788
+ if (hostIntent !== undefined) activationIntents.set(event, hostIntent);
789
+ return event;
790
+ };
770
791
  // Guarantee remote state before deciding: a date clicked before the
771
792
  // source resolves must not be treated as available, and a cancelled or
772
793
  // failed load must not authorize it either.
773
794
  const confirmed = await this.ensureDate(date);
774
795
  const state = this.getDateState(date);
775
796
  if (!confirmed || state.disabled) {
776
- this.dispatchEvent(new CustomEvent("dateinvalid", { detail: { date, state }, bubbles: true }));
797
+ this.dispatchEvent(tagged(new CustomEvent("dateinvalid", { detail: { date, state }, bubbles: true })));
777
798
  return false;
778
799
  }
779
800
  const accepted = this.dispatchEvent(
780
- new CustomEvent("dateactivate", {
781
- detail: { date, state },
782
- bubbles: true,
783
- cancelable: true,
784
- }),
801
+ tagged(
802
+ new CustomEvent("dateactivate", {
803
+ detail: { date, state },
804
+ bubbles: true,
805
+ cancelable: true,
806
+ }),
807
+ ),
785
808
  );
786
809
  if (!accepted) return false;
787
810
  if (this.selection === "single") {
@@ -820,6 +843,8 @@ export class DateCalendarElement extends HTMLElement {
820
843
  this._model.setFocused(this._clamp(date));
821
844
  this._reflect("display", this._model.display);
822
845
  if (previousDisplay !== this.display) this._emitDisplayChange();
846
+ // Engines that do not focus a clicked cell still need the tab stop moved.
847
+ else this._syncRovingDom(this._model.focused);
823
848
  void this.activateDate(date);
824
849
  }
825
850
 
@@ -832,9 +857,8 @@ export class DateCalendarElement extends HTMLElement {
832
857
  }
833
858
  if (target instanceof HTMLInputElement && target.matches(".dp-year-input")) {
834
859
  const year = Number(target.value);
835
- // 9999 is a valid civil year but its December grid overflows into 10000,
836
- // so the editable range stops one year short of the unrenderable month.
837
- if (!Number.isInteger(year) || year < 1 || year > 9998) {
860
+ // 9999-12 overflows into 10000, but _setDisplay() clamps it to 9999-11.
861
+ if (!Number.isInteger(year) || year < 1 || year > 9999) {
838
862
  if (this._connected) this.render(false);
839
863
  return;
840
864
  }
@@ -855,6 +879,8 @@ export class DateCalendarElement extends HTMLElement {
855
879
  const date = target.dataset.date || "";
856
880
  if (!isDate(date)) return;
857
881
  this._model.focused = date;
882
+ // A pointer focuses the cell directly; keep exactly one tab stop on it.
883
+ this._syncRovingDom(date);
858
884
  this.dispatchEvent(
859
885
  new CustomEvent("datefocus", { detail: { date, state: this.getDateState(date) }, bubbles: true }),
860
886
  );
@@ -915,8 +941,8 @@ export class DateCalendarElement extends HTMLElement {
915
941
  const nextKey = stepMonthKey(display, 1);
916
942
  const prevDisabled = Boolean(minMonth && prevKey !== null && prevKey < minMonth);
917
943
  const nextDisabled = Boolean(maxMonth && nextKey !== null && nextKey > maxMonth);
918
- const minYearAttr = this.min ? ` min="${Number(this.min.slice(0, 4))}"` : "";
919
- const maxYearAttr = this.max ? ` max="${Number(this.max.slice(0, 4))}"` : "";
944
+ const minYearAttr = ` min="${this.min ? Number(this.min.slice(0, 4)) : 1}"`;
945
+ const maxYearAttr = ` max="${this.max ? Number(this.max.slice(0, 4)) : 9999}"`;
920
946
  const showWeeks = this.showWeekNumbers && this.firstDay === 1;
921
947
  const weekHeader = showWeeks
922
948
  ? `<th scope="col" class="dp-week-heading" abbr="${escapeHtml(this.messages.week)}">#</th>`