@lekoala/date-picker 0.1.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lekoala/date-picker",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
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/date-calendar.js && node --check src/date-field.js && node --check src/date-picker.js && node --check src/date-range.js && node --check src/index.js && node --check src/define.js",
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/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",
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",
@@ -162,13 +162,13 @@
162
162
  "prepublishOnly": "bun run verify"
163
163
  },
164
164
  "devDependencies": {
165
- "@biomejs/biome": "2.5.11",
165
+ "@biomejs/biome": "2.5.13",
166
166
  "@lekoala/calendar": "0.1.0",
167
- "@playwright/test": "1.62.1",
167
+ "@playwright/test": "1.63.0",
168
168
  "temporal-polyfill": "^1.0.4",
169
169
  "typescript": "7.0.2"
170
170
  },
171
171
  "dependencies": {
172
- "@lekoala/floating": "^0.1.1"
172
+ "@lekoala/floating": "^0.2.0"
173
173
  }
174
174
  }
@@ -11,7 +11,14 @@ import {
11
11
  todayISO,
12
12
  } from "./date.js";
13
13
  import { normalizeRange, rangePosition } from "./date-range.js";
14
- import { formatLongDate, formatMonthYear, monthNames, resolveLocale, weekdayNames } from "./intl.js";
14
+ import {
15
+ formatLongDate,
16
+ formatMonthYear,
17
+ monthNames,
18
+ monthYearOrder,
19
+ resolveLocale,
20
+ weekdayNames,
21
+ } from "./intl.js";
15
22
  import { getDefaultMessages } from "./messages.js";
16
23
  import { normalizeDateStates } from "./source.js";
17
24
 
@@ -93,6 +100,7 @@ export class DateCalendarElement extends HTMLElement {
93
100
  "fixed-weeks",
94
101
  "show-week-numbers",
95
102
  "selection",
103
+ "month-format",
96
104
  ];
97
105
 
98
106
  constructor() {
@@ -237,6 +245,16 @@ export class DateCalendarElement extends HTMLElement {
237
245
  this.setAttribute("selection", value === "none" ? "none" : "single");
238
246
  }
239
247
 
248
+ /** @public Visible month-name style in the month select (`long` | `short`); anything else falls back to `long`. The accessible grid heading always keeps the long month/year form. */
249
+ get monthFormat() {
250
+ return this.getAttribute("month-format") === "short" ? "short" : "long";
251
+ }
252
+
253
+ set monthFormat(value) {
254
+ if (value === "short") this.setAttribute("month-format", "short");
255
+ else this.removeAttribute("month-format");
256
+ }
257
+
240
258
  /** @public */
241
259
  get highlightedRange() {
242
260
  return { ...this._highlightedRange };
@@ -368,7 +386,7 @@ export class DateCalendarElement extends HTMLElement {
368
386
 
369
387
  /** @param {HTMLElement} element @returns {string} */
370
388
  _focusTargetKey(element) {
371
- if (element.matches(".dp-month-select, .dp-year-select, .dp-year-input") && element.id) {
389
+ if (element.matches(".dp-month-select, .dp-year-input") && element.id) {
372
390
  return `#${CSS.escape(element.id)}`;
373
391
  }
374
392
  if (element.matches(".dp-nav[data-calendar-action]")) {
@@ -398,7 +416,8 @@ export class DateCalendarElement extends HTMLElement {
398
416
  const outsideBounds =
399
417
  (this.min && compareDates(date, this.min) < 0) || (this.max && compareDates(date, this.max) > 0);
400
418
  const explicitlyEnabled = state.enabled === true;
401
- // min/max > state.disabled > enabled:true lifts only isDateDisabled().
419
+ // min/max > resolved state.disabled > isDateDisabled(), unless resolved
420
+ // state.enabled === true lifts only isDateDisabled().
402
421
  const disabledByRule = explicitlyEnabled ? false : Boolean(this._isDateDisabled?.(date));
403
422
  const disabled = Boolean(outsideBounds) || Boolean(state.disabled) || disabledByRule;
404
423
  return { ...state, disabled };
@@ -624,10 +643,6 @@ export class DateCalendarElement extends HTMLElement {
624
643
  this._setDisplay(`${this.display.slice(0, 4)}-${target.value}`);
625
644
  return;
626
645
  }
627
- if (target instanceof HTMLSelectElement && target.matches(".dp-year-select")) {
628
- this._setDisplay(`${target.value}-${this.display.slice(5, 7)}`);
629
- return;
630
- }
631
646
  if (target instanceof HTMLInputElement && target.matches(".dp-year-input")) {
632
647
  const year = Number(target.value);
633
648
  if (!Number.isInteger(year) || year < 1 || year > 9999) {
@@ -688,7 +703,7 @@ export class DateCalendarElement extends HTMLElement {
688
703
  const display = this.display;
689
704
  const displayYear = yearOf(display);
690
705
  const displayMonth = Number(display.slice(5, 7));
691
- const names = monthNames(locale, "long");
706
+ const names = monthNames(locale, this.monthFormat);
692
707
  const weekdayShort = weekdayNames(locale, this.firstDay, "short");
693
708
  const weekdayLong = weekdayNames(locale, this.firstDay, "long");
694
709
  const weeks = this._weeks();
@@ -758,14 +773,22 @@ export class DateCalendarElement extends HTMLElement {
758
773
  ? this._focusTargetKey(focusedElement)
759
774
  : "";
760
775
 
776
+ // Visible month/year controls follow the locale order (Intl-derived, never
777
+ // direction-derived); the flexible header track stays under the month
778
+ // select via `data-year-first`.
779
+ const yearFirst = monthYearOrder(locale)[0] === "year";
780
+ this.toggleAttribute("data-year-first", yearFirst);
781
+ const monthControl = `<label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
782
+ <select id="${this._id}-month" class="dp-month-select" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>`;
783
+ const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
784
+ <input id="${this._id}-year" class="dp-year-input" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">`;
785
+
761
786
  this._rendering = true;
762
787
  this.innerHTML = `
763
788
  <div class="dp-calendar-shell">
764
789
  <div class="dp-calendar-header">
765
- <label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
766
- <select id="${this._id}-month" class="dp-month-select" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>
767
- <label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
768
- <input id="${this._id}-year" class="dp-year-input" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">
790
+ ${yearFirst ? yearControl : monthControl}
791
+ ${yearFirst ? monthControl : yearControl}
769
792
  <button type="button" class="dp-nav dp-prev" data-calendar-action="previous" aria-label="${escapeHtml(this._messages.previousMonth)}"${prevDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m10 4-4 4 4 4"/></svg></button>
770
793
  <button type="button" class="dp-nav dp-next" data-calendar-action="next" aria-label="${escapeHtml(this._messages.nextMonth)}"${nextDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg></button>
771
794
  </div>
@@ -779,7 +802,16 @@ export class DateCalendarElement extends HTMLElement {
779
802
 
780
803
  if (focusKey) {
781
804
  queueMicrotask(() => {
782
- const target = this.querySelector(focusKey);
805
+ let selector = focusKey;
806
+ // A day-cell key goes stale across display changes: the old date can
807
+ // reappear as outside-month padding in the new month (fixed-weeks or
808
+ // overlapping edge weeks). Restoring it would yank keyboard focus back
809
+ // onto the date the user just navigated away from, so retarget day
810
+ // cells to the current keyboard position. Controls keep exact restore.
811
+ if (selector.startsWith(".dp-day[data-date=")) {
812
+ selector = `.dp-day[data-date="${CSS.escape(this._model.focused)}"]`;
813
+ }
814
+ const target = this.querySelector(selector);
783
815
  if (target instanceof HTMLElement) target.focus();
784
816
  });
785
817
  }
package/src/date-field.js CHANGED
@@ -117,18 +117,26 @@ export class DateFieldController {
117
117
  }
118
118
 
119
119
  /** Live text input: the raw text no longer matches the canonical value, so
120
- * the hidden ISO must not submit it. */
120
+ * the hidden ISO must not submit it. A text that formats back to the current
121
+ * canonical value (e.g. re-applied by a calendar pick) keeps the ISO value
122
+ * valid; invalidating in-flight commits first still drops stale async work. */
121
123
  handleInput() {
122
124
  const input = this.input;
123
- this._dirty = true;
125
+ const text = input.value.trim();
126
+ const canonical = this.canonical ? this.adapter.format(this.canonical) : "";
124
127
  this.dirty();
128
+ if (text === canonical) {
129
+ this._dirty = false;
130
+ if (this.hidden) this.hidden.value = this.canonical;
131
+ input.setCustomValidity("");
132
+ return;
133
+ }
134
+ this._dirty = true;
125
135
  if (this.hidden) this.hidden.value = "";
126
- const text = input.value.trim();
127
136
  if (!text) {
128
137
  input.setCustomValidity("");
129
138
  return;
130
139
  }
131
- const canonical = this.canonical ? this.adapter.format(this.canonical) : "";
132
140
  input.setCustomValidity(text === canonical ? "" : this.messages.invalidDate);
133
141
  }
134
142
 
@@ -1,14 +1,20 @@
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
+ --dp-header-control-size: 2.25rem;
13
+ --dp-header-block-gap: 0.75rem;
14
+ --dp-row-gap: 0.375rem;
15
+ --dp-picker-button-size: 2.5rem;
16
+ --dp-year-inline-size: 5.5rem;
17
+ --dp-day-radius: calc(var(--dp-radius) * 0.75);
12
18
  color: var(--dp-fg);
13
19
  font: inherit;
14
20
  }
@@ -39,42 +45,85 @@ date-calendar {
39
45
  .dp-calendar-header {
40
46
  display: grid;
41
47
  grid-template-columns: minmax(0, 1fr) auto auto auto;
42
- align-items: center;
48
+ /* Stretch (not center): once a UA natural height exceeds
49
+ --dp-header-control-size, the row grows to the tallest control and the
50
+ others stretch to match, so month/year/nav stay equal. */
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). */
43
55
  gap: var(--dp-gap);
44
- margin-block-end: 0.4rem;
56
+ margin-block-end: var(--dp-header-block-gap);
45
57
  }
46
58
 
47
- .dp-calendar-header .dp-prev {
48
- margin-inline-start: calc(var(--dp-gap) * 2.5);
59
+ /* Locale year-first order (Intl-derived, direction-independent): the flexible
60
+ track stays under the month select so the fixed-width year input never
61
+ stretches. Visual mirroring itself is left to the inherited `dir`. */
62
+ date-calendar[data-year-first] .dp-calendar-header {
63
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
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;
58
79
  }
59
80
 
60
- .dp-calendar-header select,
61
81
  .dp-calendar-header input {
62
82
  min-inline-size: 0;
63
83
  padding-inline: 0.45rem;
64
84
  }
65
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. */
66
90
  .dp-month-select {
67
91
  inline-size: 100%;
92
+ min-inline-size: 0;
93
+ /* Long month names in a narrow track (compact consumers, long locales)
94
+ would paint under the caret: truncate with an ellipsis instead. */
95
+ overflow: hidden;
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%;
68
117
  }
69
118
 
70
119
  .dp-year-input {
71
- inline-size: 5.5rem;
120
+ inline-size: var(--dp-year-inline-size);
72
121
  }
73
122
 
74
123
  .dp-nav {
75
124
  display: grid;
76
125
  place-items: center;
77
- inline-size: 2.25rem;
126
+ inline-size: var(--dp-header-control-size);
78
127
  padding: 0;
79
128
  font-size: 1.35rem;
80
129
  line-height: 1;
@@ -88,6 +137,14 @@ date-calendar {
88
137
  pointer-events: none;
89
138
  }
90
139
 
140
+ /* Inherited RTL mirrors the header layout via the grid; mirror the nav glyphs
141
+ so previous/next keep their visual sense. Actions stay chronological: no
142
+ button swap, no keyboard remapping. */
143
+ [dir="rtl"] .dp-nav svg,
144
+ html[dir="rtl"] .dp-nav svg {
145
+ transform: scaleX(-1);
146
+ }
147
+
91
148
  .dp-calendar-header :where(button, select, input):disabled,
92
149
  .dp-picker-button:disabled {
93
150
  cursor: not-allowed;
@@ -98,6 +155,14 @@ date-calendar {
98
155
  background: var(--dp-hover);
99
156
  }
100
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
+
101
166
  .dp-day:hover:not([data-disabled="true"]):not([data-selected="true"]):not([data-range-start="true"]):not(
102
167
  [data-range-end="true"]
103
168
  ) {
@@ -110,8 +175,8 @@ date-calendar {
110
175
 
111
176
  .dp-grid {
112
177
  border-collapse: separate;
113
- /* --dp-gap scopes to the header grid; day-cell spacing stays fixed. */
114
- 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);
115
180
  table-layout: fixed;
116
181
  }
117
182
 
@@ -124,26 +189,41 @@ date-calendar {
124
189
  }
125
190
 
126
191
  .dp-grid thead th {
127
- block-size: 1.6rem;
192
+ block-size: 1.5rem;
128
193
  color: var(--dp-muted);
129
194
  font-size: 0.72rem;
130
- font-weight: 600;
195
+ font-weight: 500;
131
196
  }
132
197
 
133
198
  .dp-day {
134
199
  position: relative;
135
200
  border: 1px solid transparent;
136
- border-radius: calc(var(--dp-radius) * 0.75);
201
+ border-radius: var(--dp-day-radius);
137
202
  cursor: pointer;
138
203
  outline: none;
139
204
  vertical-align: middle;
140
205
  font-size: 0.875rem;
141
206
  }
142
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. */
143
212
  .dp-day:focus-visible {
144
213
  outline: 2px solid var(--dp-accent);
145
- outline-offset: 1px;
146
- 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);
220
+ }
221
+
222
+ /* Base tint for days outside the displayed month. It stays before the
223
+ selection states so selected/range endpoints keep their accent-contrast
224
+ text on the accent fill (equal specificity, source order decides). */
225
+ .dp-day[data-outside-month="true"] {
226
+ color: var(--dp-muted);
147
227
  }
148
228
 
149
229
  .dp-day[data-selected="true"] {
@@ -151,14 +231,13 @@ date-calendar {
151
231
  color: var(--dp-accent-fg);
152
232
  }
153
233
 
154
- .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"]) {
155
238
  border-color: var(--dp-accent);
156
239
  }
157
240
 
158
- .dp-day[data-outside-month="true"] {
159
- color: var(--dp-muted);
160
- }
161
-
162
241
  .dp-day[data-disabled="true"] {
163
242
  cursor: not-allowed;
164
243
  opacity: 0.42;
@@ -187,13 +266,13 @@ date-calendar {
187
266
  }
188
267
 
189
268
  .dp-day[data-in-range="true"]:first-of-type {
190
- border-start-start-radius: var(--dp-radius);
191
- 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);
192
271
  }
193
272
 
194
273
  .dp-day[data-in-range="true"]:last-of-type {
195
- border-start-end-radius: var(--dp-radius);
196
- 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);
197
276
  }
198
277
 
199
278
  .dp-day-number {
@@ -201,6 +280,17 @@ date-calendar {
201
280
  font-variant-numeric: tabular-nums;
202
281
  }
203
282
 
283
+ /* A focused disabled day stays discoverable by keyboard: restore full opacity
284
+ so the focus outline renders at full strength, and mark the number with the
285
+ muted tone (same language as outside-month days) instead of the wash-out. */
286
+ .dp-day[data-disabled="true"]:focus-visible {
287
+ opacity: 1;
288
+ }
289
+
290
+ .dp-day[data-disabled="true"]:focus-visible .dp-day-number {
291
+ color: var(--dp-muted);
292
+ }
293
+
204
294
  .dp-day-extra {
205
295
  position: absolute;
206
296
  inset-inline: 2px;
@@ -225,18 +315,95 @@ date-calendar {
225
315
  date-picker {
226
316
  position: relative;
227
317
  display: inline-grid;
228
- grid-template-columns: minmax(0, 1fr) auto;
318
+ grid-template-columns: minmax(0, 1fr);
229
319
  align-items: stretch;
230
320
  max-inline-size: 100%;
231
321
  }
232
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. */
233
332
  date-picker > input:not([type="hidden"]) {
234
- grid-column: 1;
235
333
  min-inline-size: 0;
236
334
  }
237
335
 
238
- date-picker[range] > input:not([type="hidden"]) {
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"]) {
239
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;
354
+ grid-column: 1;
355
+ grid-row: 1;
356
+ justify-self: end;
357
+ align-self: stretch;
358
+ inline-size: var(--dp-picker-button-size);
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);
374
+ }
375
+
376
+ /* Optional native time companions share the date row; authored inputs stay
377
+ consumer-styled, the picker only reserves their grid cells. Time tracks are
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. */
381
+ date-picker:has(> [data-time-start], > [data-time-end]) {
382
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
383
+ column-gap: var(--dp-gap);
384
+ }
385
+
386
+ date-picker:has(> [data-time-start]):has(> [data-time-end]) {
387
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
388
+ }
389
+
390
+ date-picker > input[data-time-start] {
391
+ grid-column: 2;
392
+ grid-row: 1;
393
+ min-inline-size: 0;
394
+ }
395
+
396
+ date-picker > input[data-time-end] {
397
+ grid-column: 3;
398
+ grid-row: 1;
399
+ min-inline-size: 0;
400
+ }
401
+
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);
240
407
  }
241
408
 
242
409
  date-picker[range] > input[data-range-start] {
@@ -247,35 +414,26 @@ date-picker[range] > input[data-range-end] {
247
414
  grid-row: 2;
248
415
  }
249
416
 
250
- .dp-picker-button {
251
- grid-column: 2;
252
- min-inline-size: 2.5rem;
253
- border: 1px solid var(--dp-border);
254
- border-inline-start: 0;
255
- border-start-start-radius: 0;
256
- border-end-start-radius: 0;
257
- border-start-end-radius: var(--dp-radius);
258
- border-end-end-radius: var(--dp-radius);
259
- background: var(--dp-bg);
260
- color: var(--dp-fg);
261
- font: inherit;
262
- cursor: pointer;
417
+ date-picker[range] > .dp-picker-button[data-endpoint="start"] {
418
+ grid-row: 1;
263
419
  }
264
420
 
265
- date-picker[range] > .dp-picker-button {
266
- grid-row: 1 / span 2;
421
+ date-picker[range] > .dp-picker-button[data-endpoint="end"] {
422
+ grid-row: 2;
267
423
  }
268
424
 
269
425
  .dp-picker-panel[popover] {
426
+ /* Safe baseline. show() switches to absolute only when the consumer
427
+ * explicitly selects document coordinate space. */
270
428
  position: fixed;
271
429
  inset: auto;
272
430
  margin: 0;
273
- padding: 0.65rem;
431
+ padding: 0.875rem 1rem;
274
432
  border: 1px solid var(--dp-border);
275
433
  border-radius: var(--dp-radius);
276
434
  background: var(--dp-bg);
277
435
  color: var(--dp-fg);
278
- 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);
279
437
  max-block-size: var(--available-height, 90svh);
280
438
  max-inline-size: calc(100vi - 1rem);
281
439
  overflow: auto;
@@ -286,19 +444,22 @@ date-picker[range] > .dp-picker-button {
286
444
  }
287
445
 
288
446
  @media (forced-colors: active) {
289
- .dp-day[data-selected="true"] {
290
- border: 2px solid Highlight;
291
- background-color: Canvas;
292
- color: CanvasText;
293
- 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;
294
454
  }
295
455
 
296
- .dp-day[data-range-start="true"],
297
- .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"]) {
298
460
  border: 2px solid Highlight;
299
461
  background-color: Canvas;
300
462
  color: CanvasText;
301
- font-weight: 700;
302
463
  }
303
464
 
304
465
  .dp-day[data-in-range="true"] {
@@ -314,15 +475,8 @@ date-picker[range] > .dp-picker-button {
314
475
  background-color: Highlight;
315
476
  }
316
477
 
317
- .dp-day[data-selected="true"]::before {
318
- content: "";
319
- position: absolute;
320
- inset: 3px;
321
- border: 1px solid CanvasText;
322
- border-radius: calc(var(--dp-radius) * 0.5);
323
- pointer-events: none;
324
- }
325
-
478
+ /* Pin the today marker: the deterministic accent is a non-system color
479
+ and would otherwise remap unpredictably here. */
326
480
  .dp-day[data-today="true"] {
327
481
  border-color: Highlight;
328
482
  }
@@ -331,11 +485,4 @@ date-picker[range] > .dp-picker-button {
331
485
  color: CanvasText;
332
486
  forced-color-adjust: none;
333
487
  }
334
-
335
- .dp-day[data-disabled="true"],
336
- .dp-calendar-header :where(button, select, input):disabled,
337
- .dp-picker-button:disabled {
338
- opacity: 1;
339
- color: GrayText;
340
- }
341
488
  }