@lekoala/date-picker 0.1.0 → 0.2.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Small, native-first date picker and inline calendar primitives.
4
4
 
5
- > v0.1: the interaction contract, event names, public styling tokens and the component API are frozen — see [docs/DECISIONS.md](docs/DECISIONS.md). Internal DOM and class names are `.dp-*` implementation details and stay private.
5
+ > v0.2: the interaction contract, event names, public styling tokens and the component API are frozen — see [docs/DECISIONS.md](docs/DECISIONS.md). Internal DOM and class names are `.dp-*` implementation details and stay private.
6
6
 
7
7
  This project fills the gap between a normal editable date field and a full scheduling calendar. A plain date input remains the right answer for simple CMS/editing forms. This component becomes useful when a date has context: availability, disabled days, annotations, remote state, start/end relationships, or a mini calendar that navigates another view.
8
8
 
@@ -10,12 +10,14 @@ This project fills the gap between a normal editable date field and a full sched
10
10
 
11
11
  ### `<date-calendar>`
12
12
 
13
- An inline calendar with real month/year navigation, keyboard support and three separate pieces of state:
13
+ An inline calendar with real month/year navigation, keyboard support and three distinct pieces of state:
14
14
 
15
15
  - `display` — the month being rendered (`YYYY-MM`);
16
16
  - `focusedDate` — the roving keyboard target (`YYYY-MM-DD`);
17
17
  - `value` — the selected date (`YYYY-MM-DD`, optional).
18
18
 
19
+ `display` and `focusedDate` are coupled during navigation/display: changing the displayed month re-clamps the focused day into that month, because the roving-tabbable grid cell must stay visible. `value` and activation stay independent of both.
20
+
19
21
  User activation is exposed separately as `dateactivate`. This is what makes the same calendar useful as a selector **and** as a mini navigator.
20
22
 
21
23
  ```html
@@ -75,6 +77,24 @@ const cleanup = linkDateRange(startPicker, endPicker);
75
77
 
76
78
  The relationship applies `end >= start` and `start <= end` as effective bounds. Neither picker knows about its sibling.
77
79
 
80
+ ## Date + time
81
+
82
+ A date pairs with one or two native time companions. The picker owns the civil date; the time inputs own their civil time values:
83
+
84
+ ```html
85
+ <date-picker>
86
+ <input name="appointment[date]">
87
+ <input type="time" data-time-start name="appointment[time]">
88
+ </date-picker>
89
+ ```
90
+
91
+ ```text
92
+ appointment[date] = 2026-09-10
93
+ appointment[time] = 09:30
94
+ ```
95
+
96
+ The application/server combines these civil values and applies the relevant timezone when a datetime/instant is needed. The picker never constructs a combined datetime value.
97
+
78
98
  ## Availability and per-day metadata
79
99
 
80
100
  The calendar accepts a source function or `{ load() }` object with the same abortable shape used elsewhere in LeKoala components:
@@ -192,7 +212,7 @@ The default ESM exports point at `src/`, matching the native-first development s
192
212
 
193
213
  ## Scope
194
214
 
195
- Included in v0.1:
215
+ Included in v0.2:
196
216
 
197
217
  - inline single-date selection;
198
218
  - mini-calendar/navigation-only use;
@@ -203,15 +223,20 @@ Included in v0.1:
203
223
  - presentation-only range band (`highlightedRange`);
204
224
  - shared-calendar two-field range picker (`<date-picker range>`);
205
225
  - linked start/end pickers (`linkDateRange`);
226
+ - native time companions (one or two `input[type=time]` beside the date);
227
+ - `Intl` month/year control order + `month-format="short"`;
228
+ - RTL through the inherited `dir` (no `isRTL` option);
229
+ - readable keyboard focus on disabled/selected days;
230
+ - compact-consumer CSS tokens (`--dp-header-control-size`, `--dp-day-radius`, …);
206
231
  - async per-day state with cancellation;
207
232
  - custom day decorations that cannot replace the accessible cell;
208
233
  - fixed six-row presentation as an opt-in;
209
234
  - ISO week numbers;
210
235
  - keyboard and focus contracts.
211
236
 
212
- Deliberately out of scope for v0.1:
237
+ Deliberately out of scope for v0.2:
213
238
 
214
- - time or datetime picking;
239
+ - custom time-picker UI and combined datetime values (only native `input[type=time]` companions are supported);
215
240
  - timezones;
216
241
  - in-place single-calendar range *selection interaction* (use the two-field picker);
217
242
  - multi-date selection;
@@ -37,6 +37,9 @@
37
37
  },
38
38
  {
39
39
  "name": "selection"
40
+ },
41
+ {
42
+ "name": "month-format"
40
43
  }
41
44
  ],
42
45
  "members": [
@@ -72,6 +75,10 @@
72
75
  "kind": "getter",
73
76
  "name": "selection"
74
77
  },
78
+ {
79
+ "kind": "getter",
80
+ "name": "monthFormat"
81
+ },
75
82
  {
76
83
  "kind": "getter",
77
84
  "name": "highlightedRange"
@@ -190,6 +197,21 @@
190
197
  },
191
198
  {
192
199
  "name": "--dp-gap"
200
+ },
201
+ {
202
+ "name": "--dp-header-control-size"
203
+ },
204
+ {
205
+ "name": "--dp-header-block-gap"
206
+ },
207
+ {
208
+ "name": "--dp-nav-gap"
209
+ },
210
+ {
211
+ "name": "--dp-year-inline-size"
212
+ },
213
+ {
214
+ "name": "--dp-day-radius"
193
215
  }
194
216
  ]
195
217
  }
@@ -228,6 +250,9 @@
228
250
  },
229
251
  {
230
252
  "name": "open-on-focus"
253
+ },
254
+ {
255
+ "name": "month-format"
231
256
  }
232
257
  ],
233
258
  "members": [
@@ -251,6 +276,10 @@
251
276
  "kind": "getter",
252
277
  "name": "locale"
253
278
  },
279
+ {
280
+ "kind": "getter",
281
+ "name": "monthFormat"
282
+ },
254
283
  {
255
284
  "kind": "getter",
256
285
  "name": "min"
@@ -347,6 +376,21 @@
347
376
  },
348
377
  {
349
378
  "name": "--dp-gap"
379
+ },
380
+ {
381
+ "name": "--dp-header-control-size"
382
+ },
383
+ {
384
+ "name": "--dp-header-block-gap"
385
+ },
386
+ {
387
+ "name": "--dp-nav-gap"
388
+ },
389
+ {
390
+ "name": "--dp-year-inline-size"
391
+ },
392
+ {
393
+ "name": "--dp-day-radius"
350
394
  }
351
395
  ]
352
396
  }
@@ -9,6 +9,11 @@
9
9
  --dp-radius: 0.5rem;
10
10
  --dp-day-size: 2.75rem;
11
11
  --dp-gap: 0.25rem;
12
+ --dp-header-control-size: 2.25rem;
13
+ --dp-header-block-gap: 0.4rem;
14
+ --dp-nav-gap: calc(var(--dp-gap) * 2.5);
15
+ --dp-year-inline-size: 5.5rem;
16
+ --dp-day-radius: calc(var(--dp-radius) * 0.75);
12
17
  color: var(--dp-fg);
13
18
  font: inherit;
14
19
  }
@@ -39,22 +44,46 @@ date-calendar {
39
44
  .dp-calendar-header {
40
45
  display: grid;
41
46
  grid-template-columns: minmax(0, 1fr) auto auto auto;
42
- align-items: center;
47
+ /* Stretch (not center): once a UA natural height exceeds
48
+ --dp-header-control-size, the row grows to the tallest control and the
49
+ others stretch to match, so month/year/nav stay equal. */
50
+ align-items: stretch;
43
51
  gap: var(--dp-gap);
44
- margin-block-end: 0.4rem;
52
+ margin-block-end: var(--dp-header-block-gap);
53
+ }
54
+
55
+ /* Locale year-first order (Intl-derived, direction-independent): the flexible
56
+ track stays under the month select so the fixed-width year input never
57
+ stretches. Visual mirroring itself is left to the inherited `dir`. */
58
+ date-calendar[data-year-first] .dp-calendar-header {
59
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
45
60
  }
46
61
 
47
62
  .dp-calendar-header .dp-prev {
48
- margin-inline-start: calc(var(--dp-gap) * 2.5);
63
+ margin-inline-start: var(--dp-nav-gap);
49
64
  }
50
65
 
51
66
  .dp-calendar-header :where(button, select, input) {
52
- min-block-size: 2.25rem;
67
+ min-block-size: var(--dp-header-control-size);
68
+ box-sizing: border-box;
69
+ /* Shared vertical metrics: UA default padding/line-height differ per control
70
+ (up to ~13px apart), which only shows once --dp-header-control-size drops
71
+ below content size. */
72
+ padding-block: 0.15rem;
53
73
  border: 1px solid var(--dp-border);
54
74
  border-radius: calc(var(--dp-radius) * 0.75);
55
75
  background: var(--dp-bg);
56
76
  color: var(--dp-fg);
57
77
  font: inherit;
78
+ line-height: 1.2;
79
+ }
80
+
81
+ /* The select menulist carries UA-internal leading that author padding only
82
+ adds to (and it ignores line-height), so it gets zero author padding.
83
+ Equal outer heights come from the header grid's stretch alignment: the row
84
+ sizes to the tallest control and the others stretch to match. */
85
+ .dp-calendar-header select {
86
+ padding-block: 0;
58
87
  }
59
88
 
60
89
  .dp-calendar-header select,
@@ -65,16 +94,20 @@ date-calendar {
65
94
 
66
95
  .dp-month-select {
67
96
  inline-size: 100%;
97
+ /* Long month names in a narrow track (compact consumers, long locales)
98
+ would paint under the native caret: truncate with an ellipsis instead. */
99
+ overflow: hidden;
100
+ text-overflow: ellipsis;
68
101
  }
69
102
 
70
103
  .dp-year-input {
71
- inline-size: 5.5rem;
104
+ inline-size: var(--dp-year-inline-size);
72
105
  }
73
106
 
74
107
  .dp-nav {
75
108
  display: grid;
76
109
  place-items: center;
77
- inline-size: 2.25rem;
110
+ inline-size: var(--dp-header-control-size);
78
111
  padding: 0;
79
112
  font-size: 1.35rem;
80
113
  line-height: 1;
@@ -88,6 +121,14 @@ date-calendar {
88
121
  pointer-events: none;
89
122
  }
90
123
 
124
+ /* Inherited RTL mirrors the header layout via the grid; mirror the nav glyphs
125
+ so previous/next keep their visual sense. Actions stay chronological: no
126
+ button swap, no keyboard remapping. */
127
+ [dir="rtl"] .dp-nav svg,
128
+ html[dir="rtl"] .dp-nav svg {
129
+ transform: scaleX(-1);
130
+ }
131
+
91
132
  .dp-calendar-header :where(button, select, input):disabled,
92
133
  .dp-picker-button:disabled {
93
134
  cursor: not-allowed;
@@ -104,12 +145,9 @@ date-calendar {
104
145
  background: var(--dp-hover);
105
146
  }
106
147
 
107
- .dp-calendar-heading {
108
- margin: 0 0 0.35rem;
109
- font-size: 0.875rem;
110
- font-weight: 600;
111
- text-align: center;
112
- }
148
+ /* .dp-calendar-heading is always visually hidden: it names the grid via
149
+ aria-labelledby and stays the aria-live announcement channel while the
150
+ month/year selects render the same info visually. */
113
151
 
114
152
  .dp-grid {
115
153
  border-collapse: separate;
@@ -136,7 +174,7 @@ date-calendar {
136
174
  .dp-day {
137
175
  position: relative;
138
176
  border: 1px solid transparent;
139
- border-radius: calc(var(--dp-radius) * 0.75);
177
+ border-radius: var(--dp-day-radius);
140
178
  cursor: pointer;
141
179
  outline: none;
142
180
  vertical-align: middle;
@@ -145,10 +183,21 @@ date-calendar {
145
183
 
146
184
  .dp-day:focus-visible {
147
185
  outline: 2px solid var(--dp-accent);
148
- outline-offset: 1px;
186
+ outline-offset: 2px;
187
+ /* Separation gap between the cell fill and the ring: invisible on plain
188
+ days, but keeps the ring readable on accent-filled days (selected, range
189
+ endpoints) where an accent-on-accent outline would disappear. */
190
+ box-shadow: 0 0 0 2px var(--dp-bg);
149
191
  z-index: 1;
150
192
  }
151
193
 
194
+ /* Base tint for days outside the displayed month. It stays before the
195
+ selection states so selected/range endpoints keep their accent-contrast
196
+ text on the accent fill (equal specificity, source order decides). */
197
+ .dp-day[data-outside-month="true"] {
198
+ color: var(--dp-muted);
199
+ }
200
+
152
201
  .dp-day[data-selected="true"] {
153
202
  background: var(--dp-accent);
154
203
  color: var(--dp-accent-fg);
@@ -158,10 +207,6 @@ date-calendar {
158
207
  border-color: var(--dp-accent);
159
208
  }
160
209
 
161
- .dp-day[data-outside-month="true"] {
162
- color: var(--dp-muted);
163
- }
164
-
165
210
  .dp-day[data-disabled="true"] {
166
211
  cursor: not-allowed;
167
212
  opacity: 0.42;
@@ -204,6 +249,17 @@ date-calendar {
204
249
  font-variant-numeric: tabular-nums;
205
250
  }
206
251
 
252
+ /* A focused disabled day stays discoverable by keyboard: restore full opacity
253
+ so the focus outline renders at full strength, and mark the number with the
254
+ muted tone (same language as outside-month days) instead of the wash-out. */
255
+ .dp-day[data-disabled="true"]:focus-visible {
256
+ opacity: 1;
257
+ }
258
+
259
+ .dp-day[data-disabled="true"]:focus-visible .dp-day-number {
260
+ color: var(--dp-muted);
261
+ }
262
+
207
263
  .dp-day-extra {
208
264
  position: absolute;
209
265
  inset-inline: 2px;
@@ -238,16 +294,56 @@ date-picker > input:not([type="hidden"]) {
238
294
  min-inline-size: 0;
239
295
  }
240
296
 
297
+ /* Optional native time companions share the date row; authored inputs stay
298
+ consumer-styled, the picker only reserves their grid cells. Time tracks are
299
+ flexible (never `auto`) so a wide UA time control cannot squeeze the date. */
300
+ date-picker:has(> [data-time-start], > [data-time-end]) {
301
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
302
+ }
303
+
304
+ date-picker:has(> [data-time-start]):has(> [data-time-end]) {
305
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) minmax(0, 1fr);
306
+ }
307
+
308
+ date-picker > input[data-time-start] {
309
+ grid-column: 3;
310
+ grid-row: 1;
311
+ min-inline-size: 0;
312
+ inline-size: 100%;
313
+ margin-inline-start: var(--dp-gap);
314
+ }
315
+
316
+ date-picker > input[data-time-end] {
317
+ grid-column: 4;
318
+ grid-row: 1;
319
+ min-inline-size: 0;
320
+ inline-size: 100%;
321
+ }
322
+
323
+ /* From/to read as one joined control: square the inner corners, but only
324
+ when both companions are present so a lone time keeps its own radius. */
325
+ date-picker:has(> [data-time-start]):has(> [data-time-end]) > input[data-time-start] {
326
+ border-start-end-radius: 0;
327
+ border-end-end-radius: 0;
328
+ }
329
+
330
+ date-picker:has(> [data-time-start]):has(> [data-time-end]) > input[data-time-end] {
331
+ border-start-start-radius: 0;
332
+ border-end-start-radius: 0;
333
+ }
334
+
241
335
  date-picker[range] > input:not([type="hidden"]) {
242
336
  grid-column: 1;
243
337
  }
244
338
 
245
339
  date-picker[range] > input[data-range-start] {
246
340
  grid-row: 1;
341
+ border-end-start-radius: 0;
247
342
  }
248
343
 
249
344
  date-picker[range] > input[data-range-end] {
250
345
  grid-row: 2;
346
+ border-start-start-radius: 0;
251
347
  }
252
348
 
253
349
  .dp-picker-button {