@lekoala/date-picker 0.2.0 → 0.3.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
@@ -162,7 +162,9 @@ The calendar follows the WAI-ARIA date-picker/grid interaction model:
162
162
 
163
163
  The picker popup uses native Popover for the top layer and `@lekoala/floating` for geometry. It has `role="dialog"`, but it is intentionally **not** marked `aria-modal` because the current implementation does not inert the rest of the document.
164
164
 
165
- When the popup opens on the field's focus, grid navigation is reachable via `ArrowDown` (or the trigger button) so the focus-restore/Escape flow stays predictable.
165
+ The calendar trigger is a real button painted inside the field's own box (like the native `input[type=time]` indicator): the field reserves its place with `padding-inline-end`, keeps its own border and background, and its focus ring wraps the trigger too. The trigger draws no chrome of its own and shows a small inner ring when focused.
166
+
167
+ When the popup opens on the field's focus, grid navigation is reachable via `ArrowDown` (or the trigger button) so the focus-restore/Escape flow stays predictable. Closing returns focus to the control that opened the popover: the field when it opened by focus, the trigger when it was activated. In range mode focus follows the active bound instead, because a first pick moves the workflow to the other bound.
166
168
 
167
169
  See [docs/ACCESSIBILITY.md](docs/ACCESSIBILITY.md).
168
170
 
@@ -179,9 +181,9 @@ Tooling is aligned with the current LeKoala component repos:
179
181
 
180
182
  - Bun 1.4.2
181
183
  - TypeScript 7.0.2 (`checkJs` + declaration emit)
182
- - Biome 2.5.11
183
- - Playwright 1.62.1
184
- - `@lekoala/floating ^0.1.1`
184
+ - Biome 2.5.13
185
+ - Playwright 1.63.0
186
+ - `@lekoala/floating ^0.2.0`
185
187
 
186
188
  Useful commands:
187
189
 
@@ -205,7 +205,10 @@
205
205
  "name": "--dp-header-block-gap"
206
206
  },
207
207
  {
208
- "name": "--dp-nav-gap"
208
+ "name": "--dp-row-gap"
209
+ },
210
+ {
211
+ "name": "--dp-picker-button-size"
209
212
  },
210
213
  {
211
214
  "name": "--dp-year-inline-size"
@@ -316,6 +319,10 @@
316
319
  "kind": "getter",
317
320
  "name": "isDateDisabled"
318
321
  },
322
+ {
323
+ "kind": "getter",
324
+ "name": "coordinateSpace"
325
+ },
319
326
  {
320
327
  "kind": "method",
321
328
  "name": "validate"
@@ -384,7 +391,10 @@
384
391
  "name": "--dp-header-block-gap"
385
392
  },
386
393
  {
387
- "name": "--dp-nav-gap"
394
+ "name": "--dp-row-gap"
395
+ },
396
+ {
397
+ "name": "--dp-picker-button-size"
388
398
  },
389
399
  {
390
400
  "name": "--dp-year-inline-size"
@@ -1,17 +1,18 @@
1
1
  :where(date-calendar, date-picker) {
2
- --dp-bg: Canvas;
3
- --dp-fg: CanvasText;
4
- --dp-muted: color-mix(in srgb, CanvasText 58%, Canvas);
5
- --dp-border: color-mix(in srgb, CanvasText 22%, Canvas);
6
- --dp-hover: color-mix(in srgb, CanvasText 7%, Canvas);
7
- --dp-accent: Highlight;
8
- --dp-accent-fg: HighlightText;
2
+ --dp-bg: #ffffff;
3
+ --dp-fg: #1f2328;
4
+ --dp-muted: color-mix(in srgb, var(--dp-fg) 58%, var(--dp-bg));
5
+ --dp-border: color-mix(in srgb, var(--dp-fg) 16%, var(--dp-bg));
6
+ --dp-hover: color-mix(in srgb, var(--dp-fg) 5%, var(--dp-bg));
7
+ --dp-accent: #2563eb;
8
+ --dp-accent-fg: #fff;
9
9
  --dp-radius: 0.5rem;
10
10
  --dp-day-size: 2.75rem;
11
- --dp-gap: 0.25rem;
11
+ --dp-gap: 0.375rem;
12
12
  --dp-header-control-size: 2.25rem;
13
- --dp-header-block-gap: 0.4rem;
14
- --dp-nav-gap: calc(var(--dp-gap) * 2.5);
13
+ --dp-header-block-gap: 0.75rem;
14
+ --dp-row-gap: 0.375rem;
15
+ --dp-picker-button-size: 2.5rem;
15
16
  --dp-year-inline-size: 5.5rem;
16
17
  --dp-day-radius: calc(var(--dp-radius) * 0.75);
17
18
  color: var(--dp-fg);
@@ -48,6 +49,9 @@ date-calendar {
48
49
  --dp-header-control-size, the row grows to the tallest control and the
49
50
  others stretch to match, so month/year/nav stay equal. */
50
51
  align-items: stretch;
52
+ /* Uniform horizontal rhythm: the four header controls are self-evident from
53
+ their kind and position, so no group gets an extra semantic gap. The air
54
+ belongs to the vertical zones (header -> weekdays -> dates). */
51
55
  gap: var(--dp-gap);
52
56
  margin-block-end: var(--dp-header-block-gap);
53
57
  }
@@ -59,10 +63,6 @@ date-calendar[data-year-first] .dp-calendar-header {
59
63
  grid-template-columns: auto minmax(0, 1fr) auto auto;
60
64
  }
61
65
 
62
- .dp-calendar-header .dp-prev {
63
- margin-inline-start: var(--dp-nav-gap);
64
- }
65
-
66
66
  .dp-calendar-header :where(button, select, input) {
67
67
  min-block-size: var(--dp-header-control-size);
68
68
  box-sizing: border-box;
@@ -78,26 +78,42 @@ date-calendar[data-year-first] .dp-calendar-header {
78
78
  line-height: 1.2;
79
79
  }
80
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;
87
- }
88
-
89
- .dp-calendar-header select,
90
81
  .dp-calendar-header input {
91
82
  min-inline-size: 0;
92
83
  padding-inline: 0.45rem;
93
84
  }
94
85
 
86
+ /* Author-drawn caret so the month select matches the nav controls without a
87
+ hardcoded SVG color (currentColor keeps theming). Equal outer heights come
88
+ from the header grid's stretch alignment: the row sizes to the tallest
89
+ control and the others stretch to match. */
95
90
  .dp-month-select {
96
91
  inline-size: 100%;
92
+ min-inline-size: 0;
97
93
  /* Long month names in a narrow track (compact consumers, long locales)
98
- would paint under the native caret: truncate with an ellipsis instead. */
94
+ would paint under the caret: truncate with an ellipsis instead. */
99
95
  overflow: hidden;
100
96
  text-overflow: ellipsis;
97
+ appearance: none;
98
+ padding-inline: 0.45rem 2rem;
99
+ background-image:
100
+ linear-gradient(45deg, transparent 50%, currentColor 50%),
101
+ linear-gradient(135deg, currentColor 50%, transparent 50%);
102
+ background-position:
103
+ calc(100% - 0.9rem) 50%,
104
+ calc(100% - 0.65rem) 50%;
105
+ background-size: 0.3rem 0.3rem;
106
+ background-repeat: no-repeat;
107
+ }
108
+
109
+ /* Inherited RTL mirrors the header layout via the grid. The logical
110
+ `padding-inline` already flips, so only the physically-positioned caret has
111
+ to follow it to the other side. */
112
+ [dir="rtl"] .dp-month-select,
113
+ html[dir="rtl"] .dp-month-select {
114
+ background-position:
115
+ 0.9rem 50%,
116
+ 0.65rem 50%;
101
117
  }
102
118
 
103
119
  .dp-year-input {
@@ -139,6 +155,14 @@ html[dir="rtl"] .dp-nav svg {
139
155
  background: var(--dp-hover);
140
156
  }
141
157
 
158
+ /* Shared control focus: accent border + light halo. Day cells keep their
159
+ dedicated outline ring below; the picker trigger has its own inner ring. */
160
+ .dp-calendar-header :where(button, select, input):focus-visible {
161
+ outline: none;
162
+ border-color: var(--dp-accent);
163
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dp-accent) 22%, transparent);
164
+ }
165
+
142
166
  .dp-day:hover:not([data-disabled="true"]):not([data-selected="true"]):not([data-range-start="true"]):not(
143
167
  [data-range-end="true"]
144
168
  ) {
@@ -151,8 +175,8 @@ html[dir="rtl"] .dp-nav svg {
151
175
 
152
176
  .dp-grid {
153
177
  border-collapse: separate;
154
- /* --dp-gap scopes to the header grid; day-cell spacing stays fixed. */
155
- border-spacing: 0 0.25rem;
178
+ /* --dp-gap scopes to the header grid; vertical week rhythm is --dp-row-gap. */
179
+ border-spacing: 0 var(--dp-row-gap);
156
180
  table-layout: fixed;
157
181
  }
158
182
 
@@ -165,10 +189,10 @@ html[dir="rtl"] .dp-nav svg {
165
189
  }
166
190
 
167
191
  .dp-grid thead th {
168
- block-size: 1.6rem;
192
+ block-size: 1.5rem;
169
193
  color: var(--dp-muted);
170
194
  font-size: 0.72rem;
171
- font-weight: 600;
195
+ font-weight: 500;
172
196
  }
173
197
 
174
198
  .dp-day {
@@ -181,14 +205,18 @@ html[dir="rtl"] .dp-nav svg {
181
205
  font-size: 0.875rem;
182
206
  }
183
207
 
208
+ /* Inner ring: keyboard focus stays inside the cell instead of reading as a
209
+ second selected state or bleeding onto a neighbouring range band. It is an
210
+ `outline` (not an inset box-shadow) because forced colors computes
211
+ box-shadow to `none`, which would erase the keyboard indicator entirely. */
184
212
  .dp-day:focus-visible {
185
213
  outline: 2px solid var(--dp-accent);
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);
191
- z-index: 1;
214
+ outline-offset: -2px;
215
+ }
216
+
217
+ /* On an accent-filled cell the ring inverts so it stays readable. */
218
+ .dp-day:is([data-selected="true"], [data-range-start="true"], [data-range-end="true"]):focus-visible {
219
+ outline-color: var(--dp-accent-fg);
192
220
  }
193
221
 
194
222
  /* Base tint for days outside the displayed month. It stays before the
@@ -203,7 +231,10 @@ html[dir="rtl"] .dp-nav svg {
203
231
  color: var(--dp-accent-fg);
204
232
  }
205
233
 
206
- .dp-day[data-today="true"]:not([data-selected="true"]) {
234
+ /* Today never competes with a stronger state: a selected day or a range
235
+ endpoint keeps its own treatment, in normal rendering and in
236
+ forced-colors alike. */
237
+ .dp-day[data-today="true"]:not([data-selected="true"], [data-range-start="true"], [data-range-end="true"]) {
207
238
  border-color: var(--dp-accent);
208
239
  }
209
240
 
@@ -235,13 +266,13 @@ html[dir="rtl"] .dp-nav svg {
235
266
  }
236
267
 
237
268
  .dp-day[data-in-range="true"]:first-of-type {
238
- border-start-start-radius: var(--dp-radius);
239
- border-end-start-radius: var(--dp-radius);
269
+ border-start-start-radius: var(--dp-day-radius);
270
+ border-end-start-radius: var(--dp-day-radius);
240
271
  }
241
272
 
242
273
  .dp-day[data-in-range="true"]:last-of-type {
243
- border-start-end-radius: var(--dp-radius);
244
- border-end-end-radius: var(--dp-radius);
274
+ border-start-end-radius: var(--dp-day-radius);
275
+ border-end-end-radius: var(--dp-day-radius);
245
276
  }
246
277
 
247
278
  .dp-day-number {
@@ -284,97 +315,125 @@ html[dir="rtl"] .dp-nav svg {
284
315
  date-picker {
285
316
  position: relative;
286
317
  display: inline-grid;
287
- grid-template-columns: minmax(0, 1fr) auto;
318
+ grid-template-columns: minmax(0, 1fr);
288
319
  align-items: stretch;
289
320
  max-inline-size: 100%;
290
321
  }
291
322
 
323
+ /* Visible focus is normalized to --dp-accent so the component holds together
324
+ without a consumer stylesheet; the field's own look stays consumer-owned. */
325
+ date-picker > input:not([type="hidden"]):focus-visible {
326
+ border-color: var(--dp-accent);
327
+ outline: none;
328
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dp-accent) 20%, transparent);
329
+ }
330
+
331
+ /* Every authored control is a grid item. */
292
332
  date-picker > input:not([type="hidden"]) {
333
+ min-inline-size: 0;
334
+ }
335
+
336
+ /* Date fields reserve room for the trigger, which is painted inside the
337
+ field's own box: the field is the real outer box, so its focus ring wraps
338
+ the affordance too. `grid-row` must stay explicit, otherwise the trigger
339
+ (placed with a definite row) claims the row and pushes the field down. */
340
+ date-picker > input:not([type="hidden"]):not([type="time"]) {
341
+ grid-column: 1;
342
+ grid-row: 1;
343
+ box-sizing: border-box;
344
+ inline-size: 100%;
345
+ padding-inline-end: calc(var(--dp-picker-button-size) + 0.5rem);
346
+ }
347
+
348
+ /* The trigger is a real button overlaid on the field's inline-end, like the
349
+ native `input[type=time]` indicator: no chrome of its own, transparent, so
350
+ the authored field background and border stay visible underneath. */
351
+ .dp-picker-button {
352
+ display: grid;
353
+ place-items: center;
293
354
  grid-column: 1;
355
+ grid-row: 1;
356
+ justify-self: end;
357
+ align-self: stretch;
358
+ inline-size: var(--dp-picker-button-size);
294
359
  min-inline-size: 0;
360
+ border: 0;
361
+ background: transparent;
362
+ color: var(--dp-fg);
363
+ font: inherit;
364
+ cursor: pointer;
365
+ }
366
+
367
+ /* Like the native time indicator: the ring wraps the icon, not the field (the
368
+ field already shows its own ring through :focus-visible). Same radius as
369
+ everywhere else: no bespoke shape. */
370
+ .dp-picker-button:focus-visible {
371
+ outline: 2px solid var(--dp-accent);
372
+ outline-offset: -5px;
373
+ border-radius: var(--dp-radius);
295
374
  }
296
375
 
297
376
  /* Optional native time companions share the date row; authored inputs stay
298
377
  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. */
378
+ flexible (never `auto`) so a wide UA time control cannot squeeze the date.
379
+ Each companion keeps its own box and its own gap: nothing is joined, so
380
+ focus rings never merge and no neighbour swallows another's focus. */
300
381
  date-picker:has(> [data-time-start], > [data-time-end]) {
301
- grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
382
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
383
+ column-gap: var(--dp-gap);
302
384
  }
303
385
 
304
386
  date-picker:has(> [data-time-start]):has(> [data-time-end]) {
305
- grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) minmax(0, 1fr);
387
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
306
388
  }
307
389
 
308
390
  date-picker > input[data-time-start] {
309
- grid-column: 3;
391
+ grid-column: 2;
310
392
  grid-row: 1;
311
393
  min-inline-size: 0;
312
- inline-size: 100%;
313
- margin-inline-start: var(--dp-gap);
314
394
  }
315
395
 
316
396
  date-picker > input[data-time-end] {
317
- grid-column: 4;
397
+ grid-column: 3;
318
398
  grid-row: 1;
319
399
  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
400
  }
329
401
 
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
-
335
- date-picker[range] > input:not([type="hidden"]) {
336
- grid-column: 1;
402
+ /* Range: two stacked fields, each owning its trigger, sharing one popup and
403
+ one calendar. No trigger spans both bounds, so each bound keeps a local
404
+ affordance and a local focus target. */
405
+ date-picker[range] {
406
+ row-gap: var(--dp-gap);
337
407
  }
338
408
 
339
409
  date-picker[range] > input[data-range-start] {
340
410
  grid-row: 1;
341
- border-end-start-radius: 0;
342
411
  }
343
412
 
344
413
  date-picker[range] > input[data-range-end] {
345
414
  grid-row: 2;
346
- border-start-start-radius: 0;
347
415
  }
348
416
 
349
- .dp-picker-button {
350
- grid-column: 2;
351
- min-inline-size: 2.5rem;
352
- border: 1px solid var(--dp-border);
353
- border-inline-start: 0;
354
- border-start-start-radius: 0;
355
- border-end-start-radius: 0;
356
- border-start-end-radius: var(--dp-radius);
357
- border-end-end-radius: var(--dp-radius);
358
- background: var(--dp-bg);
359
- color: var(--dp-fg);
360
- font: inherit;
361
- cursor: pointer;
417
+ date-picker[range] > .dp-picker-button[data-endpoint="start"] {
418
+ grid-row: 1;
362
419
  }
363
420
 
364
- date-picker[range] > .dp-picker-button {
365
- grid-row: 1 / span 2;
421
+ date-picker[range] > .dp-picker-button[data-endpoint="end"] {
422
+ grid-row: 2;
366
423
  }
367
424
 
368
425
  .dp-picker-panel[popover] {
426
+ /* Safe baseline. show() switches to absolute only when the consumer
427
+ * explicitly selects document coordinate space. */
369
428
  position: fixed;
370
429
  inset: auto;
371
430
  margin: 0;
372
- padding: 0.65rem;
431
+ padding: 0.875rem 1rem;
373
432
  border: 1px solid var(--dp-border);
374
433
  border-radius: var(--dp-radius);
375
434
  background: var(--dp-bg);
376
435
  color: var(--dp-fg);
377
- box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.16);
436
+ box-shadow: 0 0.5rem 1.5rem rgb(15 23 42 / 0.12);
378
437
  max-block-size: var(--available-height, 90svh);
379
438
  max-inline-size: calc(100vi - 1rem);
380
439
  overflow: auto;
@@ -385,19 +444,22 @@ date-picker[range] > .dp-picker-button {
385
444
  }
386
445
 
387
446
  @media (forced-colors: active) {
388
- .dp-day[data-selected="true"] {
389
- border: 2px solid Highlight;
390
- background-color: Canvas;
391
- color: CanvasText;
392
- font-weight: 700;
447
+ /* The author-drawn caret is a gradient and disappears here: hand the
448
+ control back to the browser instead of trying to rebuild it. */
449
+ .dp-month-select,
450
+ [dir="rtl"] .dp-month-select {
451
+ appearance: auto;
452
+ background-image: none;
453
+ padding-inline: 0.45rem;
393
454
  }
394
455
 
395
- .dp-day[data-range-start="true"],
396
- .dp-day[data-range-end="true"] {
456
+ /* Fills vanish here, so selection and endpoints fall back to a 2px
457
+ Highlight border. The in-range underline is the only band marker left:
458
+ the range fill is lost and the browser does not know the semantics. */
459
+ .dp-day:is([data-selected="true"], [data-range-start="true"], [data-range-end="true"]) {
397
460
  border: 2px solid Highlight;
398
461
  background-color: Canvas;
399
462
  color: CanvasText;
400
- font-weight: 700;
401
463
  }
402
464
 
403
465
  .dp-day[data-in-range="true"] {
@@ -413,15 +475,8 @@ date-picker[range] > .dp-picker-button {
413
475
  background-color: Highlight;
414
476
  }
415
477
 
416
- .dp-day[data-selected="true"]::before {
417
- content: "";
418
- position: absolute;
419
- inset: 3px;
420
- border: 1px solid CanvasText;
421
- border-radius: calc(var(--dp-radius) * 0.5);
422
- pointer-events: none;
423
- }
424
-
478
+ /* Pin the today marker: the deterministic accent is a non-system color
479
+ and would otherwise remap unpredictably here. */
425
480
  .dp-day[data-today="true"] {
426
481
  border-color: Highlight;
427
482
  }
@@ -430,11 +485,4 @@ date-picker[range] > .dp-picker-button {
430
485
  color: CanvasText;
431
486
  forced-color-adjust: none;
432
487
  }
433
-
434
- .dp-day[data-disabled="true"],
435
- .dp-calendar-header :where(button, select, input):disabled,
436
- .dp-picker-button:disabled {
437
- opacity: 1;
438
- color: GrayText;
439
- }
440
488
  }