@lekoala/date-picker 0.3.0 → 0.4.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.
@@ -2,12 +2,13 @@ import { CalendarModel } from "./calendar-model.js";
2
2
  import {
3
3
  addDays,
4
4
  clampDate,
5
+ clampDisplayMonth,
5
6
  compareDates,
7
+ endOfMonth,
6
8
  getMonthWeeks,
7
9
  isDate,
8
10
  isoWeekNumber,
9
11
  monthKey,
10
- shiftMonth,
11
12
  todayISO,
12
13
  } from "./date.js";
13
14
  import { normalizeRange, rangePosition } from "./date-range.js";
@@ -46,20 +47,43 @@ function yearOf(month) {
46
47
  return Number(month.slice(0, 4));
47
48
  }
48
49
 
50
+ /**
51
+ * Step a `YYYY-MM` key by ±1, or `null` when the step leaves the canonical
52
+ * 0001-01..9999-12 window. Pure arithmetic, so an out-of-range intermediate
53
+ * date is never constructed and the calendar edges cannot throw.
54
+ * @param {string} month
55
+ * @param {number} amount
56
+ * @returns {string | null}
57
+ */
58
+ function stepMonthKey(month, amount) {
59
+ const [year, index] = month.split("-").map(Number);
60
+ const absolute = year * 12 + (index - 1) + amount;
61
+ const stepYear = Math.floor(absolute / 12);
62
+ const stepMonth = (((absolute % 12) + 12) % 12) + 1;
63
+ if (stepYear < 1 || stepYear > 9999) return null;
64
+ return `${String(stepYear).padStart(4, "0")}-${String(stepMonth).padStart(2, "0")}`;
65
+ }
66
+
67
+ /**
68
+ * Band attributes for one day position. Markup and in-place band updates read
69
+ * the same map, so the two paths cannot disagree about a cell's state.
70
+ * @param {"" | "start" | "in" | "end" | "single"} position
71
+ * @returns {Record<string, boolean>}
72
+ */
73
+ function rangeAttributeMap(position) {
74
+ return {
75
+ "data-range-start": position === "start" || position === "single",
76
+ "data-range-end": position === "end" || position === "single",
77
+ "data-in-range": position === "in",
78
+ };
79
+ }
80
+
49
81
  /** @param {"" | "start" | "in" | "end" | "single"} position */
50
82
  function rangeAttributes(position) {
51
- switch (position) {
52
- case "single":
53
- return ' data-range-start="true" data-range-end="true"';
54
- case "start":
55
- return ' data-range-start="true"';
56
- case "end":
57
- return ' data-range-end="true"';
58
- case "in":
59
- return ' data-in-range="true"';
60
- default:
61
- return "";
62
- }
83
+ return Object.entries(rangeAttributeMap(position))
84
+ .filter(([, on]) => on)
85
+ .map(([name]) => ` ${name}="true"`)
86
+ .join("");
63
87
  }
64
88
 
65
89
  /** @param {"" | "start" | "in" | "end" | "single"} position */
@@ -125,6 +149,12 @@ export class DateCalendarElement extends HTMLElement {
125
149
  this._isDateDisabled = null;
126
150
  /** @type {DateRange} */
127
151
  this._highlightedRange = { start: "", end: "" };
152
+ // Bounds and preview flag the cells were last painted with. The preview
153
+ // flag changes the accessible names (a proposal drops the committed
154
+ // range-start/range-end suffix), so a repaint is due when either changes.
155
+ /** @type {DateRange} */
156
+ this._paintedRange = { start: "", end: "" };
157
+ this._paintedPreview = false;
128
158
  this._messages = getDefaultMessages();
129
159
  this._onClick = this._onClick.bind(this);
130
160
  this._onChange = this._onChange.bind(this);
@@ -262,9 +292,17 @@ export class DateCalendarElement extends HTMLElement {
262
292
 
263
293
  set highlightedRange(value) {
264
294
  const next = normalizeRange(value);
265
- if (next.start === this._highlightedRange.start && next.end === this._highlightedRange.end) return;
295
+ const preview = this.hasAttribute("data-range-preview");
296
+ // Repaint when the preview mode changed even if the bounds are identical:
297
+ // entering/leaving a preview rewrites every cell's accessible name.
298
+ if (
299
+ next.start === this._paintedRange.start &&
300
+ next.end === this._paintedRange.end &&
301
+ preview === this._paintedPreview
302
+ )
303
+ return;
266
304
  this._highlightedRange = next;
267
- if (this._connected) this.render();
305
+ if (this._connected) this._updateRangeDom();
268
306
  }
269
307
 
270
308
  /** @public */
@@ -343,7 +381,7 @@ export class DateCalendarElement extends HTMLElement {
343
381
  const attrDisplay = this.getAttribute("display") || "";
344
382
  if (attrDisplay) {
345
383
  try {
346
- this._model.setDisplay(attrDisplay);
384
+ this._model.setDisplay(clampDisplayMonth(attrDisplay, firstDay));
347
385
  } catch {
348
386
  // Invalid author markup falls back to the current model display.
349
387
  }
@@ -368,11 +406,21 @@ export class DateCalendarElement extends HTMLElement {
368
406
 
369
407
  /** @param {string} value */
370
408
  _clamp(value) {
371
- return clampDate(value, this.min, this.max);
409
+ const bounded = clampDate(value, this.min, this.max);
410
+ const month = monthKey(bounded);
411
+ const safeMonth = clampDisplayMonth(month, this.firstDay);
412
+ if (safeMonth === month) return bounded;
413
+ // The renderable window excludes this month (a calendar edge): keep the
414
+ // day-of-month where the target month has it, else fall back to its last
415
+ // day, so the focused date always exists in the rendered grid.
416
+ const day = Number(bounded.slice(8, 10)) || 1;
417
+ const candidate = addDays(`${safeMonth}-01`, day - 1);
418
+ const last = endOfMonth(safeMonth);
419
+ return compareDates(candidate, last) > 0 ? last : candidate;
372
420
  }
373
421
 
374
422
  _weeks(display = this.display) {
375
- const weeks = getMonthWeeks(display, { firstDay: this.firstDay });
423
+ const weeks = getMonthWeeks(clampDisplayMonth(display, this.firstDay), { firstDay: this.firstDay });
376
424
  if (!this.fixedWeeks || weeks.length >= 6) return weeks;
377
425
  let cursor = weeks[weeks.length - 1]?.[6] || "";
378
426
  while (weeks.length < 6 && cursor) {
@@ -491,7 +539,7 @@ export class DateCalendarElement extends HTMLElement {
491
539
  /** @param {string} display @param {{emit?:boolean}} [options] */
492
540
  _setDisplay(display, options = {}) {
493
541
  const previous = this._model.display;
494
- this._model.setDisplay(display);
542
+ this._model.setDisplay(clampDisplayMonth(display, this.firstDay));
495
543
  this._model.focused = this._clamp(this._model.focused);
496
544
  this._model.display = monthKey(this._model.focused);
497
545
  this._reflect("display", this._model.display);
@@ -512,12 +560,12 @@ export class DateCalendarElement extends HTMLElement {
512
560
 
513
561
  /** @public */
514
562
  previousMonth() {
515
- return this._setDisplay(shiftMonth(this.display, -1));
563
+ return this._setDisplay(stepMonthKey(this.display, -1) ?? this.display);
516
564
  }
517
565
 
518
566
  /** @public */
519
567
  nextMonth() {
520
- return this._setDisplay(shiftMonth(this.display, 1));
568
+ return this._setDisplay(stepMonthKey(this.display, 1) ?? this.display);
521
569
  }
522
570
 
523
571
  /** @public @param {string} date @param {{moveFocus?:boolean}} [options] */
@@ -572,14 +620,62 @@ export class DateCalendarElement extends HTMLElement {
572
620
  return false;
573
621
  }
574
622
 
623
+ /**
624
+ * Accessible name of one day cell. The band contribution is dropped while a
625
+ * preview is projected onto `highlightedRange`: a proposal must not be
626
+ * announced as a committed range start/end.
627
+ * @param {string} date @param {DateState} state
628
+ * @param {"" | "start" | "in" | "end" | "single"} position
629
+ */
630
+ _dayLabel(date, state, position) {
631
+ const rangeLabel = this.hasAttribute("data-range-preview") ? "" : rangeLabelKey(position);
632
+ const description = typeof state.description === "string" ? state.description : "";
633
+ return [
634
+ formatLongDate(date, this.locale),
635
+ description,
636
+ rangeLabel ? this._messages[rangeLabel] : "",
637
+ state.disabled ? this._messages.unavailable : "",
638
+ ]
639
+ .filter(Boolean)
640
+ .join(". ");
641
+ }
642
+
643
+ /**
644
+ * Repaint the range band over the cells already rendered. The band is
645
+ * presentation on top of the same grid, so it must never rebuild it: a full
646
+ * render would drop `renderDay()` nodes and fight the roving focus on every
647
+ * hover of a live range preview.
648
+ */
649
+ _updateRangeDom() {
650
+ this._paintedRange = { ...this._highlightedRange };
651
+ this._paintedPreview = this.hasAttribute("data-range-preview");
652
+ for (const cell of this.querySelectorAll(".dp-day[data-date]")) {
653
+ if (!(cell instanceof HTMLTableCellElement)) continue;
654
+ const date = cell.dataset.date || "";
655
+ const position = rangePosition(date, this._highlightedRange);
656
+ for (const [name, on] of Object.entries(rangeAttributeMap(position))) {
657
+ if (on) cell.setAttribute(name, "true");
658
+ else cell.removeAttribute(name);
659
+ }
660
+ cell.setAttribute("aria-label", this._dayLabel(date, this.getDateState(date), position));
661
+ }
662
+ }
663
+
575
664
  /** @public */
576
665
  focusGrid() {
577
666
  const target = this.querySelector(`.dp-day[data-date="${CSS.escape(this.focusedDate)}"]`);
578
667
  if (target instanceof HTMLElement) target.focus();
579
668
  }
580
669
 
581
- /** @param {string} date @returns {Promise<boolean>} */
582
- async _activate(date) {
670
+ /**
671
+ * Run one explicit activation: availability check, then the cancelable
672
+ * `dateactivate` seam, then selection. Click, keyboard and any other input
673
+ * device share this single path, so an application that cancels
674
+ * `dateactivate` cannot be bypassed by a new interaction.
675
+ * @public
676
+ * @param {string} date @returns {Promise<boolean>}
677
+ */
678
+ async activateDate(date) {
583
679
  // Guarantee remote state before deciding: a date clicked before the
584
680
  // source resolves must not be treated as available, and a cancelled or
585
681
  // failed load must not authorize it either.
@@ -633,7 +729,7 @@ export class DateCalendarElement extends HTMLElement {
633
729
  this._model.setFocused(this._clamp(date));
634
730
  this._reflect("display", this._model.display);
635
731
  if (previousDisplay !== this.display) this._emitDisplayChange();
636
- void this._activate(date);
732
+ void this.activateDate(date);
637
733
  }
638
734
 
639
735
  /** @param {Event} event */
@@ -645,7 +741,9 @@ export class DateCalendarElement extends HTMLElement {
645
741
  }
646
742
  if (target instanceof HTMLInputElement && target.matches(".dp-year-input")) {
647
743
  const year = Number(target.value);
648
- if (!Number.isInteger(year) || year < 1 || year > 9999) {
744
+ // 9999 is a valid civil year but its December grid overflows into 10000,
745
+ // so the editable range stops one year short of the unrenderable month.
746
+ if (!Number.isInteger(year) || year < 1 || year > 9998) {
649
747
  if (this._connected) this.render(false);
650
748
  return;
651
749
  }
@@ -653,12 +751,22 @@ export class DateCalendarElement extends HTMLElement {
653
751
  }
654
752
  }
655
753
 
656
- /** @param {FocusEvent} event */
754
+ /**
755
+ * Real DOM focus is the single source of truth for `datefocus`: the model is
756
+ * synced here and the event is announced from here, so the temporary focus
757
+ * arithmetic in `_onKeyDown` cannot emit phantom events, and setting
758
+ * `focusedDate` without moving focus stays silent.
759
+ * @param {FocusEvent} event
760
+ */
657
761
  _onFocusIn(event) {
658
762
  const target = event.target;
659
763
  if (!(target instanceof HTMLTableCellElement) || !target.matches(".dp-day[data-date]")) return;
660
764
  const date = target.dataset.date || "";
661
- if (isDate(date)) this._model.focused = date;
765
+ if (!isDate(date)) return;
766
+ this._model.focused = date;
767
+ this.dispatchEvent(
768
+ new CustomEvent("datefocus", { detail: { date, state: this.getDateState(date) }, bubbles: true }),
769
+ );
662
770
  }
663
771
 
664
772
  /** @param {KeyboardEvent} event */
@@ -668,7 +776,7 @@ export class DateCalendarElement extends HTMLElement {
668
776
  const date = target.dataset.date || this.focusedDate;
669
777
  if (event.key === "Enter" || event.key === " ") {
670
778
  event.preventDefault();
671
- void this._activate(date);
779
+ void this.activateDate(date);
672
780
  return;
673
781
  }
674
782
  // Movement primitives live in CalendarModel; the DOM only syncs render/focus.
@@ -712,8 +820,10 @@ export class DateCalendarElement extends HTMLElement {
712
820
  const gridId = `${this._id}-grid`;
713
821
  const minMonth = this.min ? monthKey(this.min) : "";
714
822
  const maxMonth = this.max ? monthKey(this.max) : "";
715
- const prevDisabled = Boolean(minMonth && shiftMonth(display, -1) < minMonth);
716
- const nextDisabled = Boolean(maxMonth && shiftMonth(display, 1) > maxMonth);
823
+ const prevKey = stepMonthKey(display, -1);
824
+ const nextKey = stepMonthKey(display, 1);
825
+ const prevDisabled = Boolean(minMonth && prevKey !== null && prevKey < minMonth);
826
+ const nextDisabled = Boolean(maxMonth && nextKey !== null && nextKey > maxMonth);
717
827
  const minYearAttr = this.min ? ` min="${Number(this.min.slice(0, 4))}"` : "";
718
828
  const maxYearAttr = this.max ? ` max="${Number(this.max.slice(0, 4))}"` : "";
719
829
  const showWeeks = this.showWeekNumbers && this.firstDay === 1;
@@ -750,16 +860,7 @@ export class DateCalendarElement extends HTMLElement {
750
860
  const focused = this.focusedDate === date;
751
861
  const isToday = date === today;
752
862
  const position = rangePosition(date, this._highlightedRange);
753
- const rangeLabel = rangeLabelKey(position);
754
- const description = typeof state.description === "string" ? state.description : "";
755
- const label = [
756
- formatLongDate(date, locale),
757
- description,
758
- rangeLabel ? this._messages[rangeLabel] : "",
759
- state.disabled ? this._messages.unavailable : "",
760
- ]
761
- .filter(Boolean)
762
- .join(". ");
863
+ const label = this._dayLabel(date, state, position);
763
864
  return `<td class="dp-day" data-date="${date}"${outside ? ' data-outside-month="true"' : ""}${isToday ? ' data-today="true" aria-current="date"' : ""}${selected ? ' data-selected="true" aria-selected="true"' : ""}${rangeAttributes(position)}${state.disabled ? ' data-disabled="true" aria-disabled="true"' : ""} tabindex="${focused ? "0" : "-1"}" aria-label="${escapeHtml(label)}"><span class="dp-day-number" aria-hidden="true">${Number(date.slice(8, 10))}</span><span class="dp-day-extra" aria-hidden="true"></span></td>`;
764
865
  })
765
866
  .join("");
@@ -789,8 +890,8 @@ export class DateCalendarElement extends HTMLElement {
789
890
  <div class="dp-calendar-header">
790
891
  ${yearFirst ? yearControl : monthControl}
791
892
  ${yearFirst ? monthControl : yearControl}
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>
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>
893
+ <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="m4 10 4-4 4 4"/></svg></button>
894
+ <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="m4 6 4 4 4-4"/></svg></button>
794
895
  </div>
795
896
  <h2 id="${headingId}" class="dp-calendar-heading dp-visually-hidden" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
796
897
  <table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
@@ -799,6 +900,8 @@ export class DateCalendarElement extends HTMLElement {
799
900
  </table>
800
901
  </div>`;
801
902
  this._rendering = false;
903
+ this._paintedRange = { ...this._highlightedRange };
904
+ this._paintedPreview = this.hasAttribute("data-range-preview");
802
905
 
803
906
  if (focusKey) {
804
907
  queueMicrotask(() => {
package/src/date-field.js CHANGED
@@ -33,8 +33,9 @@ export class DateFieldController {
33
33
  this.onAttributesChanged = null;
34
34
  /**
35
35
  * Async availability gate for a parsed date. Returning `{ ok: false }`
36
- * marks the target unavailable. Swappable at runtime.
37
- * @type {((date: string) => Promise<{ok: boolean, message?: string}>) | null}
36
+ * marks the target unavailable. Swappable at runtime. The commit options
37
+ * are forwarded so the gate can honor `moveDisplay`.
38
+ * @type {((date: string, options?: {moveDisplay?: boolean}) => Promise<{ok: boolean, message?: string}>) | null}
38
39
  */
39
40
  this.confirm = null;
40
41
  }
@@ -144,9 +145,12 @@ export class DateFieldController {
144
145
  * Commit the visible text. Returns the parsed canonical date on success, the
145
146
  * empty string on a clear, or a falsy marker for invalid/stale commits. The
146
147
  * caller applies the canonical value so reflection and events stay central.
148
+ * `options` is forwarded to the `confirm` gate so a revalidation can ask it
149
+ * not to move the calendar display (source reloads must not re-home it).
150
+ * @param {{moveDisplay?: boolean}} [options]
147
151
  * @returns {Promise<{status: "ok" | "clear" | "invalid" | "stale", value?: string}>}
148
152
  */
149
- async commit() {
153
+ async commit(options = {}) {
150
154
  const input = this.input;
151
155
  const commitId = ++this._commitId;
152
156
  const text = input.value.trim();
@@ -163,7 +167,7 @@ export class DateFieldController {
163
167
  return { status: "invalid" };
164
168
  }
165
169
  if (this.confirm) {
166
- const result = await this.confirm(parsed);
170
+ const result = await this.confirm(parsed, options);
167
171
  if (commitId !== this._commitId) return { status: "stale" };
168
172
  if (!result?.ok) {
169
173
  if (this.hidden) this.hidden.value = "";
@@ -6,6 +6,8 @@
6
6
  --dp-hover: color-mix(in srgb, var(--dp-fg) 5%, var(--dp-bg));
7
7
  --dp-accent: #2563eb;
8
8
  --dp-accent-fg: #fff;
9
+ --dp-focus: var(--dp-accent);
10
+ --dp-focus-width: 2px;
9
11
  --dp-radius: 0.5rem;
10
12
  --dp-day-size: 2.75rem;
11
13
  --dp-gap: 0.375rem;
@@ -137,14 +139,6 @@ html[dir="rtl"] .dp-month-select {
137
139
  pointer-events: none;
138
140
  }
139
141
 
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
-
148
142
  .dp-calendar-header :where(button, select, input):disabled,
149
143
  .dp-picker-button:disabled {
150
144
  cursor: not-allowed;
@@ -155,12 +149,13 @@ html[dir="rtl"] .dp-nav svg {
155
149
  background: var(--dp-hover);
156
150
  }
157
151
 
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);
152
+ /* Inner outlines survive forced colors and keep focus inside each control.
153
+ The picker trigger uses a tighter inset around its icon below. */
154
+ .dp-calendar-header :where(button, select, input):focus-visible,
155
+ date-picker > input:not([type="hidden"]):focus-visible,
156
+ .dp-day:focus-visible {
157
+ outline: var(--dp-focus-width) solid var(--dp-focus);
158
+ outline-offset: calc(var(--dp-focus-width) * -1);
164
159
  }
165
160
 
166
161
  .dp-day:hover:not([data-disabled="true"]):not([data-selected="true"]):not([data-range-start="true"]):not(
@@ -205,15 +200,6 @@ html[dir="rtl"] .dp-nav svg {
205
200
  font-size: 0.875rem;
206
201
  }
207
202
 
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. */
212
- .dp-day:focus-visible {
213
- outline: 2px solid var(--dp-accent);
214
- outline-offset: -2px;
215
- }
216
-
217
203
  /* On an accent-filled cell the ring inverts so it stays readable. */
218
204
  .dp-day:is([data-selected="true"], [data-range-start="true"], [data-range-end="true"]):focus-visible {
219
205
  outline-color: var(--dp-accent-fg);
@@ -275,6 +261,28 @@ html[dir="rtl"] .dp-nav svg {
275
261
  border-end-end-radius: var(--dp-day-radius);
276
262
  }
277
263
 
264
+ /* Projected band: the range the next commit would produce. It reads as one
265
+ step short of the committed band, so a hovered, keyboard or dragged preview
266
+ is never mistaken for the selection itself. */
267
+ [data-range-preview] .dp-day:is([data-range-start="true"], [data-range-end="true"]) {
268
+ background: color-mix(in srgb, var(--dp-accent) 58%, var(--dp-bg));
269
+ }
270
+
271
+ [data-range-preview] .dp-day[data-in-range="true"] {
272
+ background: color-mix(in srgb, var(--dp-accent) 11%, var(--dp-bg));
273
+ }
274
+
275
+ /* Endpoint handles are draggable only while the committed range is complete
276
+ and its two ends sit on different days. */
277
+ [data-range-drag] .dp-day:is([data-range-start="true"], [data-range-end="true"]) {
278
+ cursor: grab;
279
+ }
280
+
281
+ [data-range-drag="active"],
282
+ [data-range-drag="active"] .dp-day {
283
+ cursor: grabbing;
284
+ }
285
+
278
286
  .dp-day-number {
279
287
  display: block;
280
288
  font-variant-numeric: tabular-nums;
@@ -320,14 +328,6 @@ date-picker {
320
328
  max-inline-size: 100%;
321
329
  }
322
330
 
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
331
  /* Every authored control is a grid item. */
332
332
  date-picker > input:not([type="hidden"]) {
333
333
  min-inline-size: 0;
@@ -368,7 +368,7 @@ date-picker > input:not([type="hidden"]):not([type="time"]) {
368
368
  field already shows its own ring through :focus-visible). Same radius as
369
369
  everywhere else: no bespoke shape. */
370
370
  .dp-picker-button:focus-visible {
371
- outline: 2px solid var(--dp-accent);
371
+ outline: var(--dp-focus-width) solid var(--dp-focus);
372
372
  outline-offset: -5px;
373
373
  border-radius: var(--dp-radius);
374
374
  }