@reliability-design/web 1.4.5 → 1.4.6

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.
Files changed (42) hide show
  1. package/dist/components/rel-alert/rel-alert.ts +1 -1
  2. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  3. package/dist/components/rel-badge/rel-badge.ts +6 -1
  4. package/dist/components/rel-calendar/rel-calendar.ts +64 -18
  5. package/dist/components/rel-carousel/rel-carousel.ts +25 -2
  6. package/dist/components/rel-chip/rel-chip.ts +31 -20
  7. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  8. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  9. package/dist/components/rel-date-picker/rel-date-picker.ts +529 -90
  10. package/dist/components/rel-drawer/rel-drawer.ts +6 -3
  11. package/dist/components/rel-menu/rel-menu.ts +6 -0
  12. package/dist/components/rel-otp/rel-otp.ts +12 -2
  13. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  14. package/dist/components/rel-popover/rel-popover.ts +33 -2
  15. package/dist/components/rel-progress/rel-progress.ts +13 -4
  16. package/dist/components/rel-radio/README.md +6 -3
  17. package/dist/components/rel-radio/rel-radio.ts +236 -60
  18. package/dist/components/rel-range-picker/rel-range-picker.ts +452 -95
  19. package/dist/components/rel-segmented-button/rel-segmented-button.ts +5 -1
  20. package/dist/components/rel-select/rel-select.ts +44 -13
  21. package/dist/components/rel-sheet/rel-sheet.ts +163 -18
  22. package/dist/components/rel-sidebar/rel-sidebar.ts +7 -1
  23. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  24. package/dist/components/rel-slider/rel-slider.ts +6 -3
  25. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  26. package/dist/components/rel-stepper/rel-stepper.ts +3 -1
  27. package/dist/components/rel-switch/rel-switch.ts +3 -1
  28. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  29. package/dist/components/rel-time-picker/rel-time-picker.ts +450 -94
  30. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  31. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  32. package/dist/components/rel-toast/rel-toast.ts +4 -2
  33. package/dist/components/rel-tooltip/README.md +13 -5
  34. package/dist/components/rel-tooltip/rel-tooltip.ts +3 -0
  35. package/dist/components/rel-upload/rel-upload.ts +25 -0
  36. package/dist/index.d.mts +241 -11
  37. package/dist/index.d.ts +241 -11
  38. package/dist/index.js +2573 -598
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +2614 -640
  41. package/dist/index.mjs.map +1 -1
  42. package/package.json +2 -2
@@ -1,7 +1,10 @@
1
1
  import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state, query } from "lit/decorators.js";
3
+ import { live } from "lit/directives/live.js";
3
4
  import { formatDatePattern } from "./date-format";
5
+ import { parseDateInput, isDayFirstPattern, isDayFirstLocale } from "./date-parse";
4
6
  import { getDeepActiveElement, trapTabKey } from "../../utils/focus.js";
7
+ import { announce } from "../../utils/announce";
5
8
 
6
9
  interface CalendarDay {
7
10
  date: Date;
@@ -75,8 +78,20 @@ export class RelDatePicker extends LitElement {
75
78
  @state() private focusedDate: Date | null = null;
76
79
  @state() private yearDropdownOpen = false;
77
80
  @state() private monthDropdownOpen = false;
81
+ /** Uncommitted typed text; null → the input shows the committed value. */
82
+ @state() private inputText: string | null = null;
83
+ /** Parse/range error message for the typed text (cleared on next input). */
84
+ @state() private inputError: string | null = null;
78
85
 
79
- @query(".trigger-button") private triggerButton?: HTMLButtonElement;
86
+ /**
87
+ * Whether the next popover open should move focus into the calendar grid.
88
+ * Icon-click / ArrowDown / external `open` do (previous behavior); opening
89
+ * by clicking into the text input must NOT, or typing would be impossible.
90
+ */
91
+ private focusPanelOnOpen = true;
92
+
93
+ @query(".trigger-input") private triggerInput?: HTMLInputElement;
94
+ @query(".trigger-button") private triggerButton?: HTMLElement;
80
95
  @query(".popover") private popoverElement?: HTMLElement;
81
96
  @query(".calendar-grid") private calendarGrid?: HTMLElement;
82
97
 
@@ -153,9 +168,14 @@ export class RelDatePicker extends LitElement {
153
168
  color: var(--rel-text-danger);
154
169
  }
155
170
 
171
+ /* Persistent live region — out of flow while empty so it adds no gap. */
172
+ .date-picker-support.empty {
173
+ position: absolute;
174
+ }
175
+
156
176
  .trigger-button {
157
177
  width: 100%;
158
- min-width: 200px;
178
+ min-width: min(200px, 100%);
159
179
  display: flex;
160
180
  align-items: center;
161
181
  justify-content: space-between;
@@ -175,28 +195,48 @@ export class RelDatePicker extends LitElement {
175
195
  letter-spacing: var(--rel-body-md-regular-letter-spacing);
176
196
  }
177
197
 
178
- .trigger-button:hover:not(:disabled) {
198
+ :host(:not([disabled])) .trigger-button:hover {
179
199
  border-color: var(--rel-border-strong);
180
200
  }
181
201
 
182
- .trigger-button:focus-visible {
202
+ .trigger-button:focus-within {
183
203
  outline: 2px solid var(--rel-border-focus);
184
204
  outline-offset: 2px;
185
205
  border-color: var(--rel-border-focus);
186
206
  }
187
207
 
188
- .trigger-button:disabled {
208
+ :host([disabled]) .trigger-button {
189
209
  cursor: not-allowed;
190
210
  background: var(--rel-bg-surface-elevated);
191
211
  }
192
212
 
213
+ /* The editable field itself — the wrapper carries the box styles. */
214
+ .trigger-input {
215
+ flex: 1;
216
+ min-width: 0;
217
+ border: none;
218
+ background: transparent;
219
+ padding: 0;
220
+ margin: 0;
221
+ outline: none;
222
+ color: var(--rel-text-default);
223
+ font: inherit;
224
+ letter-spacing: inherit;
225
+ cursor: inherit;
226
+ }
227
+
228
+ .trigger-input::placeholder {
229
+ color: var(--rel-text-placeholder);
230
+ opacity: 1;
231
+ }
232
+
193
233
  /* Error state */
194
234
  .trigger-button.error,
195
- .trigger-button.error:hover:not(:disabled) {
235
+ :host(:not([disabled])) .trigger-button.error:hover {
196
236
  border-color: var(--rel-border-danger);
197
237
  }
198
238
 
199
- .trigger-button.error:focus-visible {
239
+ .trigger-button.error:focus-within {
200
240
  outline-color: var(--rel-border-danger);
201
241
  border-color: var(--rel-border-danger);
202
242
  }
@@ -220,15 +260,6 @@ export class RelDatePicker extends LitElement {
220
260
  letter-spacing: var(--rel-body-lg-regular-letter-spacing);
221
261
  }
222
262
 
223
- .trigger-content {
224
- flex: 1;
225
- color: var(--rel-text-default);
226
- }
227
-
228
- .trigger-content.placeholder {
229
- color: var(--rel-text-placeholder);
230
- }
231
-
232
263
  .trigger-icons {
233
264
  display: flex;
234
265
  align-items: center;
@@ -259,16 +290,34 @@ export class RelDatePicker extends LitElement {
259
290
  }
260
291
 
261
292
  .popover {
293
+ /* Reset native [popover] UA styles first (the panel is promoted to
294
+ the top layer so a drawer/sheet/modal ancestor — transformed,
295
+ overflow:hidden, or an equal-z-index sibling panel — can't clip it
296
+ or paint over it). While in the top layer, JS overrides the
297
+ anchored position below with inline fixed coordinates; the CSS
298
+ anchoring is the no-Popover-API fallback. */
299
+ margin: 0;
300
+ width: auto;
301
+ height: auto;
302
+ inset: auto;
303
+ color: inherit;
262
304
  position: absolute;
263
305
  top: calc(100% + 4px);
264
306
  left: 0;
265
- z-index: 1000;
307
+ z-index: 2147483647;
266
308
  background: var(--rel-bg-surface);
267
309
  border: 1px solid var(--rel-border-default);
268
310
  border-radius: var(--rel-border-radius-sm);
269
311
  box-shadow: var(--rel-shadows-shadow-300);
270
312
  padding: var(--rel-spacing-md);
271
- min-width: 280px;
313
+ /* Never wider or taller than the viewport (small screens). border-box
314
+ so the caps include border+padding; 314px = the previous 280px
315
+ content-box min-width, keeping desktop rendering unchanged. */
316
+ box-sizing: border-box;
317
+ min-width: min(314px, calc(100vw - 16px));
318
+ max-width: calc(100vw - 16px);
319
+ max-height: min(600px, calc(100vh - 16px));
320
+ overflow-y: auto;
272
321
  display: none;
273
322
  }
274
323
 
@@ -276,6 +325,56 @@ export class RelDatePicker extends LitElement {
276
325
  display: block;
277
326
  }
278
327
 
328
+ /* Typed-shortcut hints (T → Today, …); each chip applies its value. */
329
+ .shortcut-row {
330
+ display: flex;
331
+ flex-wrap: wrap;
332
+ gap: var(--rel-spacing-2xs);
333
+ padding-bottom: var(--rel-spacing-xs);
334
+ margin-bottom: var(--rel-spacing-xs);
335
+ border-bottom: 1px solid var(--rel-border-subtle);
336
+ }
337
+
338
+ .shortcut-chip {
339
+ display: inline-flex;
340
+ align-items: center;
341
+ gap: var(--rel-spacing-2xs);
342
+ border: none;
343
+ background: transparent;
344
+ border-radius: var(--rel-border-radius-xs);
345
+ padding: var(--rel-spacing-3xs) var(--rel-spacing-2xs);
346
+ cursor: pointer;
347
+ color: var(--rel-text-default);
348
+ transition: background 0.15s ease;
349
+ font-family: var(--rel-body-xs-regular-font-family);
350
+ font-weight: var(--rel-body-xs-regular-font-weight);
351
+ font-size: var(--rel-body-xs-regular-font-size);
352
+ line-height: var(--rel-body-xs-regular-line-height);
353
+ letter-spacing: var(--rel-body-xs-regular-letter-spacing);
354
+ }
355
+
356
+ .shortcut-chip:hover {
357
+ background: var(--rel-bg-neutral-elevated-1);
358
+ }
359
+
360
+ .shortcut-chip:focus-visible {
361
+ outline: 2px solid var(--rel-border-focus);
362
+ outline-offset: -2px;
363
+ }
364
+
365
+ .shortcut-chip kbd {
366
+ border: 1px solid var(--rel-border-default);
367
+ border-bottom-width: 2px;
368
+ border-radius: var(--rel-border-radius-xs);
369
+ background: var(--rel-bg-surface-elevated);
370
+ padding: 0 var(--rel-spacing-3xs);
371
+ min-width: 18px;
372
+ text-align: center;
373
+ font-family: inherit;
374
+ font-size: inherit;
375
+ color: var(--rel-text-label);
376
+ }
377
+
279
378
  .calendar-header {
280
379
  display: flex;
281
380
  align-items: center;
@@ -414,6 +513,13 @@ export class RelDatePicker extends LitElement {
414
513
  gap: var(--rel-spacing-3xs);
415
514
  }
416
515
 
516
+ .calendar-week {
517
+ grid-column: 1 / -1;
518
+ display: grid;
519
+ grid-template-columns: repeat(7, 1fr);
520
+ gap: var(--rel-spacing-3xs);
521
+ }
522
+
417
523
  .day-button {
418
524
  aspect-ratio: 1;
419
525
  border: none;
@@ -496,6 +602,9 @@ export class RelDatePicker extends LitElement {
496
602
  super.disconnectedCallback();
497
603
  document.removeEventListener("click", this.handleClickOutside);
498
604
  document.removeEventListener("keydown", this.handleEscapeKey);
605
+ window.removeEventListener("resize", this.onReposition);
606
+ window.removeEventListener("scroll", this.onReposition, true);
607
+ this.hideTopLayer();
499
608
  }
500
609
 
501
610
  private handleClickOutside = (e: MouseEvent) => {
@@ -503,7 +612,8 @@ export class RelDatePicker extends LitElement {
503
612
 
504
613
  const path = e.composedPath();
505
614
  if (!path.includes(this)) {
506
- this.closeCalendarPopover();
615
+ // Don't refocus the field — the user clicked somewhere else on purpose.
616
+ this.closeCalendarPopover(false);
507
617
  }
508
618
  };
509
619
 
@@ -529,22 +639,118 @@ export class RelDatePicker extends LitElement {
529
639
  }
530
640
  }
531
641
 
532
- if (changedProperties.has("open") && this.open) {
642
+ // `open` is a public reflected property (consumers may set it directly,
643
+ // not only via the trigger), so top-layer promotion syncs here rather
644
+ // than inside toggle/close.
645
+ if (changedProperties.has("open")) {
646
+ if (!this.open) {
647
+ window.removeEventListener("resize", this.onReposition);
648
+ window.removeEventListener("scroll", this.onReposition, true);
649
+ this.hideTopLayer();
650
+ this.focusPanelOnOpen = true;
651
+ return;
652
+ }
653
+ this.showTopLayer();
654
+ window.addEventListener("resize", this.onReposition);
655
+ window.addEventListener("scroll", this.onReposition, true);
533
656
  // Focus the roving cell (selected day, else today, else first
534
- // selectable) when opening — not blindly the first of the month.
535
- setTimeout(() => {
536
- const target =
537
- this.calendarGrid?.querySelector<HTMLButtonElement>(
538
- '.day-button[tabindex="0"]:not(:disabled)'
539
- ) ??
540
- this.calendarGrid?.querySelector<HTMLButtonElement>(
541
- ".day-button:not(:disabled)"
542
- );
543
- target?.focus();
544
- }, 100);
657
+ // selectable) when opening — not blindly the first of the month —
658
+ // unless the open came from clicking into the text input (typing must
659
+ // keep working).
660
+ const focusPanel = this.focusPanelOnOpen;
661
+ this.focusPanelOnOpen = true;
662
+ if (focusPanel) {
663
+ setTimeout(() => this.focusPanelSelection(), 100);
664
+ }
545
665
  }
546
666
  }
547
667
 
668
+ /* A drawer/sheet/modal ancestor breaks the anchored panel two ways: a
669
+ * transform makes it the containing block for position:fixed descendants
670
+ * (and overflow:hidden clips absolute ones), and an equal-z-index panel
671
+ * later in the DOM paints over the calendar. Promoting the panel to the
672
+ * browser top layer sidesteps both — same fix as rel-popover /
673
+ * rel-autocomplete / rel-select / rel-range-picker. `manual` keeps
674
+ * outside-click and Escape handling under this component's own control.
675
+ * While in the top layer the CSS anchoring is meaningless, so the panel
676
+ * gets inline fixed coordinates from updatePopoverPosition(). */
677
+
678
+ private showTopLayer() {
679
+ const el = this.popoverElement;
680
+ if (el && typeof el.showPopover === "function") {
681
+ try {
682
+ if (!el.matches(":popover-open")) el.showPopover();
683
+ } catch {
684
+ /* older engines / detached element — CSS fallback applies */
685
+ }
686
+ }
687
+ requestAnimationFrame(() => this.updatePopoverPosition());
688
+ }
689
+
690
+ private hideTopLayer() {
691
+ const el = this.popoverElement;
692
+ if (!el) return;
693
+ if (typeof el.hidePopover === "function") {
694
+ try {
695
+ if (el.matches(":popover-open")) el.hidePopover();
696
+ } catch {
697
+ /* already hidden or unsupported — nothing to do */
698
+ }
699
+ }
700
+ el.style.position = "";
701
+ el.style.top = "";
702
+ el.style.left = "";
703
+ }
704
+
705
+ private onReposition = () => {
706
+ if (this.open) this.updatePopoverPosition();
707
+ };
708
+
709
+ private updatePopoverPosition = () => {
710
+ const panel = this.popoverElement;
711
+ const anchor = this.triggerButton;
712
+ if (!panel || !anchor || !this.open) return;
713
+
714
+ const t = anchor.getBoundingClientRect();
715
+ const c = panel.getBoundingClientRect();
716
+
717
+ // Below the trigger; flip above when there's no room, clamp to the
718
+ // viewport with an 8px margin on every edge.
719
+ let top = t.bottom + 4;
720
+ if (top + c.height > window.innerHeight - 8) {
721
+ top = Math.max(8, t.top - c.height - 4);
722
+ }
723
+ let left = t.left;
724
+ if (left + c.width > window.innerWidth - 8) {
725
+ left = window.innerWidth - c.width - 8;
726
+ }
727
+ left = Math.max(8, left);
728
+
729
+ // Without top-layer support a transformed ancestor may be the containing
730
+ // block for position:fixed, shifting the panel away from the viewport
731
+ // coordinates computed above. Apply, re-measure, and cancel the offset.
732
+ panel.style.position = "fixed";
733
+ panel.style.top = `${top}px`;
734
+ panel.style.left = `${left}px`;
735
+ const applied = panel.getBoundingClientRect();
736
+ top -= applied.top - top;
737
+ left -= applied.left - left;
738
+ panel.style.top = `${top}px`;
739
+ panel.style.left = `${left}px`;
740
+ };
741
+
742
+ /** Focus the roving day cell (selected day, else today, else first). */
743
+ private focusPanelSelection() {
744
+ const target =
745
+ this.calendarGrid?.querySelector<HTMLButtonElement>(
746
+ '.day-button[tabindex="0"]:not(:disabled)'
747
+ ) ??
748
+ this.calendarGrid?.querySelector<HTMLButtonElement>(
749
+ ".day-button:not(:disabled)"
750
+ );
751
+ target?.focus();
752
+ }
753
+
548
754
  private parseISODate(iso: string): Date | null {
549
755
  const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
550
756
  if (!match) return null;
@@ -688,6 +894,26 @@ export class RelDatePicker extends LitElement {
688
894
  return formatter.format(new Date(this.viewYear, this.viewMonth, 1));
689
895
  }
690
896
 
897
+ private dayLabelFormatter: Intl.DateTimeFormat | null = null;
898
+ private dayLabelLocale = "";
899
+
900
+ private formatDayLabel(date: Date): string {
901
+ if (!this.dayLabelFormatter || this.dayLabelLocale !== this.locale) {
902
+ this.dayLabelLocale = this.locale;
903
+ this.dayLabelFormatter = new Intl.DateTimeFormat(this.locale, {
904
+ weekday: "long",
905
+ month: "long",
906
+ day: "numeric",
907
+ year: "numeric",
908
+ });
909
+ }
910
+ return this.dayLabelFormatter.format(date);
911
+ }
912
+
913
+ private announceMonth() {
914
+ announce(`${this.getMonthName()} ${this.viewYear}`);
915
+ }
916
+
691
917
  private getMonthsList(): { value: number; label: string }[] {
692
918
  const months = [];
693
919
  for (let i = 0; i < 12; i++) {
@@ -732,11 +958,11 @@ export class RelDatePicker extends LitElement {
732
958
  );
733
959
  }
734
960
 
735
- private closeCalendarPopover() {
961
+ private closeCalendarPopover(refocus = true) {
736
962
  this.open = false;
737
963
  this.monthDropdownOpen = false;
738
964
  this.yearDropdownOpen = false;
739
- this.triggerButton?.focus();
965
+ if (refocus) this.triggerInput?.focus();
740
966
  this.dispatchEvent(
741
967
  new CustomEvent("rel-open-change", {
742
968
  detail: { open: false },
@@ -753,6 +979,7 @@ export class RelDatePicker extends LitElement {
753
979
  } else {
754
980
  this.viewMonth--;
755
981
  }
982
+ this.announceMonth();
756
983
  }
757
984
 
758
985
  private nextMonth() {
@@ -762,33 +989,40 @@ export class RelDatePicker extends LitElement {
762
989
  } else {
763
990
  this.viewMonth++;
764
991
  }
992
+ this.announceMonth();
765
993
  }
766
994
 
767
995
  private selectDay(day: CalendarDay) {
768
996
  if (day.isDisabled || this.disabled || this.readonly) return;
997
+ this.commitDate(day.date);
998
+ if (this.closeOnSelect) {
999
+ this.closeCalendarPopover();
1000
+ }
1001
+ }
769
1002
 
770
- const newValue = this.formatISODate(day.date);
1003
+ /** Commit a date: set value, reset typed text/error, fire rel-change. */
1004
+ private commitDate(date: Date) {
1005
+ const newValue = this.formatISODate(date);
771
1006
  this.value = newValue;
772
-
1007
+ this.inputText = null;
1008
+ this.inputError = null;
773
1009
  this.dispatchEvent(
774
1010
  new CustomEvent("rel-change", {
775
1011
  detail: {
776
1012
  value: newValue,
777
- date: new Date(day.date),
1013
+ date: new Date(date),
778
1014
  },
779
1015
  bubbles: true,
780
1016
  composed: true,
781
1017
  })
782
1018
  );
783
-
784
- if (this.closeOnSelect) {
785
- this.closeCalendarPopover();
786
- }
787
1019
  }
788
1020
 
789
1021
  private clearValue(e: Event) {
790
1022
  e.stopPropagation();
791
1023
  this.value = null;
1024
+ this.inputText = null;
1025
+ this.inputError = null;
792
1026
 
793
1027
  this.dispatchEvent(
794
1028
  new CustomEvent("rel-change", {
@@ -802,15 +1036,148 @@ export class RelDatePicker extends LitElement {
802
1036
  );
803
1037
  }
804
1038
 
805
- private handleTriggerKeydown(e: KeyboardEvent) {
806
- if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
1039
+ // ── Typed keyboard entry ──
1040
+
1041
+ /** Ambiguous "a/b" day/month order: display format wins, then locale. */
1042
+ private get dayFirst(): boolean {
1043
+ if (this.displayFormat) {
1044
+ const fromPattern = isDayFirstPattern(this.displayFormat);
1045
+ if (fromPattern !== null) return fromPattern;
1046
+ }
1047
+ return isDayFirstLocale(this.locale);
1048
+ }
1049
+
1050
+ /**
1051
+ * Parse and commit the typed text (Enter / blur). Invalid or out-of-range
1052
+ * text keeps the field in an error state with the text preserved for
1053
+ * correction; the committed value is untouched (Escape reverts the text).
1054
+ */
1055
+ private commitTypedValue() {
1056
+ if (this.inputText === null) return; // nothing typed since last commit
1057
+ const text = this.inputText.trim();
1058
+
1059
+ if (!text) {
1060
+ // Field cleared by keyboard — behaves like the clear button.
1061
+ this.inputText = null;
1062
+ this.inputError = null;
1063
+ if (this.value !== null) {
1064
+ this.value = null;
1065
+ this.dispatchEvent(
1066
+ new CustomEvent("rel-change", {
1067
+ detail: { value: null, date: null },
1068
+ bubbles: true,
1069
+ composed: true,
1070
+ })
1071
+ );
1072
+ }
1073
+ if (this.open) this.closeCalendarPopover();
1074
+ return;
1075
+ }
1076
+
1077
+ const parsed = parseDateInput(text, {
1078
+ referenceDate: new Date(),
1079
+ dayFirst: this.dayFirst,
1080
+ locale: this.locale,
1081
+ });
1082
+ if (!parsed) {
1083
+ this.inputError = "Invalid date";
1084
+ return;
1085
+ }
1086
+ // Midnight-anchored like the calendar grid, so min/max compares match.
1087
+ const date = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
1088
+ if (this.isDateDisabledInternal(date)) {
1089
+ this.inputError = "Date not available";
1090
+ return;
1091
+ }
1092
+
1093
+ this.commitDate(date);
1094
+ if (this.open) this.closeCalendarPopover();
1095
+ }
1096
+
1097
+ /** Today ± offset days, committed directly (T shortcuts / popover chips). */
1098
+ private applyDayOffsetShortcut(offsetDays: number) {
1099
+ const now = new Date();
1100
+ const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offsetDays);
1101
+ if (this.isDateDisabledInternal(date)) {
1102
+ this.inputError = "Date not available";
1103
+ return;
1104
+ }
1105
+ this.commitDate(date);
1106
+ if (this.open) this.closeCalendarPopover();
1107
+ }
1108
+
1109
+ private handleInputInput(e: Event) {
1110
+ this.inputText = (e.target as HTMLInputElement).value;
1111
+ if (this.inputError) this.inputError = null;
1112
+ }
1113
+
1114
+ private handleInputKeydown(e: KeyboardEvent) {
1115
+ if (e.key === "Enter") {
1116
+ e.preventDefault();
1117
+ if (this.inputText === null) {
1118
+ // Nothing typed — Enter opens the popover (previous trigger behavior).
1119
+ if (!this.open) this.toggleCalendarPopover();
1120
+ } else {
1121
+ this.commitTypedValue();
1122
+ }
1123
+ return;
1124
+ }
1125
+ if (e.key === "ArrowDown") {
807
1126
  e.preventDefault();
808
1127
  if (!this.open) {
809
1128
  this.toggleCalendarPopover();
1129
+ } else {
1130
+ this.focusPanelSelection();
810
1131
  }
1132
+ return;
1133
+ }
1134
+ if (e.key === "Escape" && this.inputText !== null) {
1135
+ // Revert the typed text; the document listener closes the popover.
1136
+ this.inputText = null;
1137
+ this.inputError = null;
811
1138
  }
812
1139
  }
813
1140
 
1141
+ /** Clicking anywhere on the field opens the popover, keeping focus typed-in. */
1142
+ private handleFieldClick() {
1143
+ if (this.open || this.disabled || this.readonly) return;
1144
+ this.focusPanelOnOpen = false;
1145
+ this.toggleCalendarPopover();
1146
+ }
1147
+
1148
+ /** The calendar icon toggles the popover with the previous focus behavior. */
1149
+ private handleIconClick(e: Event) {
1150
+ e.stopPropagation();
1151
+ if (this.open) {
1152
+ this.closeCalendarPopover();
1153
+ } else {
1154
+ this.toggleCalendarPopover();
1155
+ }
1156
+ }
1157
+
1158
+ /** Commit typed text when focus leaves the component entirely. */
1159
+ private handleInputBlur = (e: FocusEvent) => {
1160
+ this.dispatchEvent(new CustomEvent("rel-blur", { bubbles: true, composed: true }));
1161
+ const related = e.relatedTarget as Node | null;
1162
+ if (related && (this.renderRoot.contains(related) || this.contains(related))) {
1163
+ return; // focus moved into the panel/icons — their own handlers commit
1164
+ }
1165
+ this.commitTypedValue();
1166
+ };
1167
+
1168
+ /** T inside the panel (grid/header focus) applies Today directly. */
1169
+ private handlePanelKeydown = (e: KeyboardEvent) => {
1170
+ if (
1171
+ (e.key === "t" || e.key === "T") &&
1172
+ !e.ctrlKey &&
1173
+ !e.metaKey &&
1174
+ !e.altKey
1175
+ ) {
1176
+ e.preventDefault();
1177
+ this.applyDayOffsetShortcut(0);
1178
+ }
1179
+ };
1180
+
814
1181
  private handleDayKeydown(e: KeyboardEvent, day: CalendarDay, index: number) {
815
1182
  const days = this.getCalendarDays();
816
1183
  let targetIndex = index;
@@ -844,6 +1211,7 @@ export class RelDatePicker extends LitElement {
844
1211
  e.preventDefault();
845
1212
  if (e.shiftKey) {
846
1213
  this.viewYear--;
1214
+ this.announceMonth();
847
1215
  } else {
848
1216
  this.previousMonth();
849
1217
  }
@@ -852,6 +1220,7 @@ export class RelDatePicker extends LitElement {
852
1220
  e.preventDefault();
853
1221
  if (e.shiftKey) {
854
1222
  this.viewYear++;
1223
+ this.announceMonth();
855
1224
  } else {
856
1225
  this.nextMonth();
857
1226
  }
@@ -975,6 +1344,7 @@ export class RelDatePicker extends LitElement {
975
1344
  ?.querySelector<HTMLElement>(".dropdown-trigger");
976
1345
  this.viewMonth = month;
977
1346
  this.monthDropdownOpen = false;
1347
+ this.announceMonth();
978
1348
  // The focused item just went display:none — keep keyboard users anchored.
979
1349
  this.updateComplete.then(() => trigger?.focus());
980
1350
  }
@@ -986,6 +1356,7 @@ export class RelDatePicker extends LitElement {
986
1356
  ?.querySelector<HTMLElement>(".dropdown-trigger");
987
1357
  this.viewYear = year;
988
1358
  this.yearDropdownOpen = false;
1359
+ this.announceMonth();
989
1360
  this.updateComplete.then(() => trigger?.focus());
990
1361
  }
991
1362
 
@@ -1015,10 +1386,17 @@ export class RelDatePicker extends LitElement {
1015
1386
  const months = this.getMonthsList();
1016
1387
  const years = this.getYearsList();
1017
1388
  const rovingIndex = this.getRovingIndex(days);
1389
+ const weeks: CalendarDay[][] = [];
1390
+ for (let i = 0; i < days.length; i += 7) {
1391
+ weeks.push(days.slice(i, i + 7));
1392
+ }
1018
1393
 
1019
- const isError = this.error;
1020
- const belowText = isError && this.errorMessage ? this.errorMessage : this.helperText;
1394
+ const isError = this.error || this.inputError !== null;
1395
+ const belowText = isError
1396
+ ? this.errorMessage ?? this.inputError ?? this.helperText
1397
+ : this.helperText;
1021
1398
  const describedById = belowText ? "date-picker-desc" : undefined;
1399
+ const displayText = this.value ? this.formatDisplayDate(this.value) : "";
1022
1400
  const wrapperClasses = [
1023
1401
  "date-picker-wrapper",
1024
1402
  this.size,
@@ -1042,24 +1420,35 @@ export class RelDatePicker extends LitElement {
1042
1420
  ? html`<input type="hidden" name=${this.name} .value=${this.value ?? ""} />`
1043
1421
  : nothing}
1044
1422
 
1045
- <button
1423
+ <div
1424
+ part="trigger"
1046
1425
  class="trigger-button ${isError ? "error" : ""}"
1047
- type="button"
1048
- ?disabled=${this.disabled}
1049
- @click=${this.toggleCalendarPopover}
1050
- @keydown=${this.handleTriggerKeydown}
1051
- aria-label=${this.label || "Select date"}
1052
- aria-haspopup="dialog"
1053
- aria-expanded=${this.open}
1054
- aria-required=${this.required}
1055
- aria-invalid=${isError}
1056
- aria-readonly=${this.readonly}
1057
- aria-describedby=${describedById ?? nothing}
1058
- aria-labelledby=${this.label ? "date-picker-label" : nothing}
1426
+ @click=${this.handleFieldClick}
1059
1427
  >
1060
- <span class="trigger-content ${this.value ? "" : "placeholder"}">
1061
- ${this.value ? this.formatDisplayDate(this.value) : this.placeholder}
1062
- </span>
1428
+ <input
1429
+ part="value"
1430
+ class="trigger-input"
1431
+ type="text"
1432
+ .value=${live(this.inputText ?? displayText)}
1433
+ placeholder=${this.placeholder}
1434
+ ?disabled=${this.disabled}
1435
+ ?readonly=${this.readonly}
1436
+ autocomplete="off"
1437
+ spellcheck="false"
1438
+ enterkeyhint="done"
1439
+ @input=${this.handleInputInput}
1440
+ @keydown=${this.handleInputKeydown}
1441
+ @blur=${this.handleInputBlur}
1442
+ role="combobox"
1443
+ aria-label=${this.label || "Select date"}
1444
+ aria-haspopup="dialog"
1445
+ aria-expanded=${this.open}
1446
+ aria-controls="date-picker-panel"
1447
+ aria-required=${this.required}
1448
+ aria-invalid=${isError}
1449
+ aria-describedby=${describedById ?? nothing}
1450
+ aria-labelledby=${this.label ? "date-picker-label" : nothing}
1451
+ />
1063
1452
  <span class="trigger-icons">
1064
1453
  ${this.clearable && this.value
1065
1454
  ? html`
@@ -1080,15 +1469,57 @@ export class RelDatePicker extends LitElement {
1080
1469
  </button>
1081
1470
  `
1082
1471
  : nothing}
1083
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1084
- <path
1085
- d="M4 2h1V1h2v1h2V1h2v1h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 3H4v7h8V5z"
1086
- />
1087
- </svg>
1472
+ <button
1473
+ class="icon-button"
1474
+ type="button"
1475
+ aria-label="Open calendar"
1476
+ aria-expanded=${this.open}
1477
+ ?disabled=${this.disabled}
1478
+ @click=${this.handleIconClick}
1479
+ >
1480
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1481
+ <path
1482
+ d="M4 2h1V1h2v1h2V1h2v1h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 3H4v7h8V5z"
1483
+ />
1484
+ </svg>
1485
+ </button>
1088
1486
  </span>
1089
- </button>
1487
+ </div>
1090
1488
 
1091
- <div class="popover ${this.open ? "open" : ""}" role="dialog" aria-label="Choose date">
1489
+ <div
1490
+ id="date-picker-panel"
1491
+ class="popover ${this.open ? "open" : ""}"
1492
+ popover="manual"
1493
+ role="dialog"
1494
+ aria-label="Choose date"
1495
+ @keydown=${this.handlePanelKeydown}
1496
+ >
1497
+ <div part="shortcuts" class="shortcut-row">
1498
+ <button
1499
+ class="shortcut-chip"
1500
+ type="button"
1501
+ title="Type T in the field for today"
1502
+ @click=${() => this.applyDayOffsetShortcut(0)}
1503
+ >
1504
+ <kbd aria-hidden="true">T</kbd><span>Today</span>
1505
+ </button>
1506
+ <button
1507
+ class="shortcut-chip"
1508
+ type="button"
1509
+ title="Type T+1 in the field for tomorrow"
1510
+ @click=${() => this.applyDayOffsetShortcut(1)}
1511
+ >
1512
+ <kbd aria-hidden="true">T+1</kbd><span>Tomorrow</span>
1513
+ </button>
1514
+ <button
1515
+ class="shortcut-chip"
1516
+ type="button"
1517
+ title="Type T-1 in the field for yesterday"
1518
+ @click=${() => this.applyDayOffsetShortcut(-1)}
1519
+ >
1520
+ <kbd aria-hidden="true">T-1</kbd><span>Yesterday</span>
1521
+ </button>
1522
+ </div>
1092
1523
  <div class="calendar-header">
1093
1524
  <button
1094
1525
  class="nav-button"
@@ -1203,34 +1634,42 @@ export class RelDatePicker extends LitElement {
1203
1634
  )}
1204
1635
  </div>
1205
1636
 
1206
- <div class="calendar-grid" role="grid">
1207
- ${days.map(
1208
- (day, index) => html`
1209
- <button
1210
- class="day-button ${day.isSelected ? "selected" : ""} ${day.isToday
1211
- ? "today"
1212
- : ""} ${!day.isCurrentMonth ? "other-month" : ""}"
1213
- role="gridcell"
1214
- type="button"
1215
- aria-label="${this.formatISODate(day.date)}"
1216
- aria-selected="${day.isSelected}"
1217
- ?disabled=${day.isDisabled}
1218
- @click=${() => this.selectDay(day)}
1219
- @keydown=${(e: KeyboardEvent) => this.handleDayKeydown(e, day, index)}
1220
- tabindex="${index === rovingIndex ? 0 : -1}"
1221
- >
1222
- ${day.day}
1223
- </button>
1637
+ <div class="calendar-grid" role="grid" aria-label="${this.getMonthName()} ${this.viewYear}">
1638
+ ${weeks.map(
1639
+ (week, weekIndex) => html`
1640
+ <div class="calendar-week" role="row">
1641
+ ${week.map((day, dayIndex) => {
1642
+ const index = weekIndex * 7 + dayIndex;
1643
+ return html`
1644
+ <button
1645
+ class="day-button ${day.isSelected ? "selected" : ""} ${day.isToday
1646
+ ? "today"
1647
+ : ""} ${!day.isCurrentMonth ? "other-month" : ""}"
1648
+ role="gridcell"
1649
+ type="button"
1650
+ aria-label="${this.formatDayLabel(day.date)}"
1651
+ aria-selected="${day.isSelected}"
1652
+ aria-current=${day.isToday ? "date" : nothing}
1653
+ ?disabled=${day.isDisabled}
1654
+ @click=${() => this.selectDay(day)}
1655
+ @keydown=${(e: KeyboardEvent) => this.handleDayKeydown(e, day, index)}
1656
+ tabindex="${index === rovingIndex ? 0 : -1}"
1657
+ >
1658
+ ${day.day}
1659
+ </button>
1660
+ `;
1661
+ })}
1662
+ </div>
1224
1663
  `
1225
1664
  )}
1226
1665
  </div>
1227
1666
  </div>
1228
1667
 
1229
- ${belowText
1230
- ? html`<div class="date-picker-support ${isError ? "error" : ""}" id=${describedById}>
1231
- ${belowText}
1232
- </div>`
1233
- : nothing}
1668
+ <div
1669
+ class="date-picker-support ${isError ? "error" : ""} ${belowText ? "" : "empty"}"
1670
+ id="date-picker-desc"
1671
+ aria-live="polite"
1672
+ >${belowText ?? nothing}</div>
1234
1673
  </div>
1235
1674
  `;
1236
1675
  }